Skip to content
Merged
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
5 changes: 4 additions & 1 deletion lib/components/FileMenuLeftHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const FileMenuLeftHeader = (props: {
onChangeShouldLoadLatestEval?: (shouldLoadLatestEval: boolean) => void
circuitJson?: any
projectName?: string
onLoginRequired?: () => void
}) => {
const lastRunEvalVersion = useRunnerStore((s) => s.lastRunEvalVersion)
const currentMainComponentPath = useRunFrameStore(
Expand Down Expand Up @@ -166,7 +167,9 @@ export const FileMenuLeftHeader = (props: {
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false)
const [isAiReviewDialogOpen, setIsAiReviewDialogOpen] = useState(false)

const { BugReportDialog, openBugReportDialog } = useBugReportDialog()
const { BugReportDialog, openBugReportDialog } = useBugReportDialog({
onLoginRequired: props.onLoginRequired,
})

const handleLbrnExport = async (options: LbrnExportOptions) => {
if (!pendingLbrnExport) return
Expand Down
270 changes: 270 additions & 0 deletions lib/components/RunFrameForCli/LoginDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import { useCallback, useEffect, useRef, useState } from "react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../ui/dialog"
import { Button } from "../ui/button"
import { useRunFrameStore } from "../RunFrameWithApi/store"
import { toast } from "lib/utils/toast"
import { getRegistryKy } from "lib/utils/get-registry-ky"

const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))

export interface LoginDialogProps {
isOpen: boolean
onClose: () => void
onLoginSuccess?: () => void
onOpen?: () => void
}

type LoginState =
| "idle"
| "creating"
| "waiting"
| "exchanging"
| "success"
| "error"

export const LoginDialog = ({
isOpen,
onClose,
onLoginSuccess,
}: LoginDialogProps) => {
const [loginState, setLoginState] = useState<LoginState>("idle")
const [loginUrl, setLoginUrl] = useState<string | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const pushEvent = useRunFrameStore((state) => state.pushEvent)
const abortControllerRef = useRef<AbortController | null>(null)

useEffect(() => {
return () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
}
}, [])

const handleSignIn = useCallback(async () => {
setLoginState("creating")
setErrorMessage(null)
abortControllerRef.current = new AbortController()

try {
const ky = getRegistryKy()

const { login_page } = await ky
.post("sessions/login_page/create", {
json: {},
signal: abortControllerRef.current?.signal,
})
.json<{
login_page: {
login_page_id: string
login_page_auth_token: string
url: string
}
}>()

setLoginUrl(login_page.url)
setLoginState("waiting")

const urlWithAutoclose = new URL(login_page.url)
urlWithAutoclose.searchParams.set("autoclose", "true")
window.open(urlWithAutoclose.toString(), "_blank")

while (!abortControllerRef.current?.signal.aborted) {
const { login_page: updatedLoginPage } = await ky
.post("sessions/login_page/get", {
json: {
login_page_id: login_page.login_page_id,
},
headers: {
Authorization: `Bearer ${login_page.login_page_auth_token}`,
},
signal: abortControllerRef.current?.signal,
})
.json<{
login_page: { was_login_successful: boolean; is_expired: boolean }
}>()

if (updatedLoginPage.was_login_successful) {
setLoginState("exchanging")
break
}

if (updatedLoginPage.is_expired) {
throw new Error("Login page expired. Please try again.")
}

await delay(1000)
}

if (abortControllerRef.current?.signal.aborted) {
setLoginState("idle")
return
}

const { session } = await ky
.post("sessions/login_page/exchange_for_cli_session", {
json: {
login_page_id: login_page.login_page_id,
},
headers: {
Authorization: `Bearer ${login_page.login_page_auth_token}`,
},
signal: abortControllerRef.current?.signal,
})
.json<{ session: { token: string } }>()

if (typeof window !== "undefined") {
window.TSCIRCUIT_REGISTRY_TOKEN = session.token
}

await pushEvent({
event_type: "TOKEN_UPDATED",
registry_token: session.token,
})

setLoginState("success")
toast.success("Successfully logged in!")
onLoginSuccess?.()

setTimeout(() => {
onClose()
setLoginState("idle")
setLoginUrl(null)
}, 1000)
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
setLoginState("idle")
return
}
console.error("Login error:", error)
const message =
error instanceof Error ? error.message : "Failed to complete login"
setErrorMessage(message)
setLoginState("error")
toast.error(message)
}
}, [pushEvent, onLoginSuccess, onClose])

const handleCancel = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
setLoginState("idle")
setLoginUrl(null)
setErrorMessage(null)
onClose()
}, [onClose])

const isLoading =
loginState === "creating" ||
loginState === "waiting" ||
loginState === "exchanging"

const getStatusMessage = () => {
switch (loginState) {
case "creating":
return "Creating login page..."
case "waiting":
return "Waiting for you to complete login in the browser..."
case "exchanging":
return "Generating token..."
case "success":
return "Successfully logged in!"
case "error":
return errorMessage || "An error occurred"
default:
return null
}
}

return (
<Dialog
open={isOpen}
onOpenChange={(open) => !open && !isLoading && handleCancel()}
>
<DialogContent className="rf-max-w-md">
<DialogHeader>
<DialogTitle>Sign In Required</DialogTitle>
<DialogDescription>
{loginState === "idle" || loginState === "error"
? "Sign in to continue. A new browser window will open for authentication."
: "Complete the login process in the opened browser window."}
</DialogDescription>
</DialogHeader>
<div className="rf-flex rf-flex-col rf-items-center rf-gap-4 rf-py-4">
{loginState === "idle" || loginState === "error" ? (
<Button onClick={handleSignIn} className="rf-w-full">
Sign In
</Button>
) : (
<div className="rf-flex rf-flex-col rf-items-center rf-gap-3 rf-w-full">
{loginState !== "success" && (
<div className="rf-animate-spin rf-w-6 rf-h-6 rf-border-2 rf-border-gray-300 rf-border-t-blue-600 rf-rounded-full" />
)}
<p className="rf-text-sm rf-text-gray-600 rf-text-center">
{getStatusMessage()}
</p>
{loginUrl && loginState === "waiting" && (
<a
href={loginUrl}
target="_blank"
rel="noreferrer"
className="rf-text-xs rf-text-blue-600 hover:rf-underline"
>
Click here if the window didn't open
</a>
)}
</div>
)}
{errorMessage && loginState === "error" && (
<p className="rf-text-sm rf-text-red-600 rf-text-center">
{errorMessage}
</p>
)}
{isLoading && (
<Button
variant="outline"
onClick={handleCancel}
className="rf-w-full"
>
Cancel
</Button>
)}
</div>
</DialogContent>
</Dialog>
)
}

export const useLoginDialog = () => {
const [isOpen, setIsOpen] = useState(false)

const openLoginDialog = useCallback(() => {
setIsOpen(true)
}, [])

const closeLoginDialog = useCallback(() => {
setIsOpen(false)
}, [])

const handleLoginSuccess = useCallback(() => {
setIsOpen(false)
}, [])

return {
LoginDialog: (
<LoginDialog
isOpen={isOpen}
onClose={closeLoginDialog}
onLoginSuccess={handleLoginSuccess}
/>
),
openLoginDialog,
}
}
59 changes: 34 additions & 25 deletions lib/components/RunFrameForCli/RunFrameForCli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useLocalStorageState } from "lib/hooks/use-local-storage-state"
import { useCallback, useState } from "react"
import { RunFrameWithApi } from "../RunFrameWithApi/RunFrameWithApi"
import { FileMenuLeftHeader } from "../FileMenuLeftHeader"
import { useLoginDialog } from "./LoginDialog"

export const RunFrameForCli = (props: {
debug?: boolean
Expand Down Expand Up @@ -31,31 +32,39 @@ export const RunFrameForCli = (props: {
window.history.replaceState(null, "", newUrl)
}, [])

const { LoginDialog, openLoginDialog } = useLoginDialog()

return (
<RunFrameWithApi
debug={props.debug}
forceLatestEvalVersion={!props.workerBlobUrl && shouldLoadLatestEval}
defaultToFullScreen={true}
showToggleFullScreen={false}
workerBlobUrl={props.workerBlobUrl}
showFilesSwitch
showFileMenu={false}
enableFetchProxy={props.enableFetchProxy}
initialMainComponentPath={initialMainComponentPath}
onMainComponentPathChange={updateMainComponentHash}
leftHeaderContent={
<div className="rf-flex rf-items-center rf-justify-between">
<FileMenuLeftHeader
isWebEmbedded={false}
shouldLoadLatestEval={!props.workerBlobUrl && shouldLoadLatestEval}
onChangeShouldLoadLatestEval={(newShouldLoadLatestEval) => {
setLoadLatestEval(newShouldLoadLatestEval)
globalThis.runFrameWorker = null
}}
/>
{props.scenarioSelectorContent}
</div>
}
/>
<>
{LoginDialog}
<RunFrameWithApi
debug={props.debug}
forceLatestEvalVersion={!props.workerBlobUrl && shouldLoadLatestEval}
defaultToFullScreen={true}
showToggleFullScreen={false}
workerBlobUrl={props.workerBlobUrl}
showFilesSwitch
showFileMenu={false}
enableFetchProxy={props.enableFetchProxy}
initialMainComponentPath={initialMainComponentPath}
onMainComponentPathChange={updateMainComponentHash}
leftHeaderContent={
<div className="rf-flex rf-items-center rf-justify-between">
<FileMenuLeftHeader
isWebEmbedded={false}
shouldLoadLatestEval={
!props.workerBlobUrl && shouldLoadLatestEval
}
onChangeShouldLoadLatestEval={(newShouldLoadLatestEval) => {
setLoadLatestEval(newShouldLoadLatestEval)
globalThis.runFrameWorker = null
}}
onLoginRequired={openLoginDialog}
/>
{props.scenarioSelectorContent}
</div>
}
/>
</>
)
}
8 changes: 8 additions & 0 deletions lib/components/RunFrameWithApi/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ export interface RequestToExportSnippetEvent {
created_at: string
}

export interface TokenUpdatedEvent {
event_id: string
event_type: "TOKEN_UPDATED"
created_at: string
registry_token: string
}

export type RunFrameEvent =
| FileUpdatedEvent
| InitialFilesUploadedEvent
Expand All @@ -90,6 +97,7 @@ export type RunFrameEvent =
| SnippetExportCreatedEvent
| RequestToExportSnippetEvent
| InstallPackageEvent
| TokenUpdatedEvent

type MappedOmit<T, K extends keyof T> = {
[P in keyof T as P extends K ? never : P]: T[P]
Expand Down
Loading
Loading