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
15 changes: 7 additions & 8 deletions apps/frontend/src/atoms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,18 @@ import { atom } from "jotai"
*
* Almost nothing does. What the user is looking at is the URL's job, and a run
* belongs to the script whose page it happens on — so that lives in the pane and
* resets when the pane changes script. What is left is the user we run as, which is
* worth typing once, and the delete dialog's target, because the dialog is mounted
* above the routes.
* resets when the pane changes script. What is left is the user we run as, which
* the auth chip fills from the signed-in email, and the delete dialog's target,
* because the dialog is mounted above the routes.
*/

/**
* The Arcade end user tools execute as. Kept across scripts; it rarely changes.
* The Arcade end user tools execute as. Kept across scripts.
*
* When OIDC login is configured, the rail auth chip overwrites this with the
* signed-in account id (`sub`). The default remains a real account so local
* runs without auth still reach authorized tools instead of looking broken.
* The rail auth chip sets this from the signed-in email and clears it on sign
* out. Empty means not signed in; a run then gets a 401 with an authorization URL.
*/
export const userIdAtom = atom("anirudh@arcade.dev")
export const userIdAtom = atom("")

/** Which script the delete dialog is about to remove. */
export const deleteTargetAtom = atom<string | null>(null)
Expand Down
9 changes: 5 additions & 4 deletions apps/frontend/src/components/auth-status.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Session chip in the rail: sign in / out, and mirror the Arcade account id
* into the run-as atom so tool calls use the signed-in identity.
* Session chip in the rail: sign in / out, and mirror the signed-in email into
* the run-as atom so tool calls use that identity.
*/
import { Link } from "@tanstack/react-router"
import { useSetAtom } from "jotai"
Expand All @@ -14,9 +14,10 @@ export function AuthStatus() {
const setUserId = useSetAtom(userIdAtom)

useEffect(() => {
if (isPending) return
const id = session?.user ? arcadeUserIdFromSession(session.user) : null
if (id) setUserId(id)
}, [session, setUserId])
setUserId(id ?? "")
}, [session, isPending, setUserId])

if (isPending) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ function Workbench({
const [sheetOpen, setSheetOpen] = useState(detailsOpen)
const [chatVisible, setChatVisible] = useState(chatOpen)
const [prompt, setPrompt] = useState("")
const [userId, setUserId] = useState("anirudh@arcade.dev")
const userId = "anirudh@arcade.dev"
const [inputJson, setInputJson] = useState(
'{\n "owner": "arcadeai",\n "repo": "arcade-ai",\n "number": 481\n}\n'
)
Expand Down Expand Up @@ -130,7 +130,6 @@ function Workbench({
onInputJsonChange={setInputJson}
onRun={noop}
onShowDetails={() => setSheetOpen(true)}
onUserIdChange={setUserId}
report={report}
running={running}
script={script}
Expand Down
2 changes: 0 additions & 2 deletions apps/frontend/src/components/scripts/panes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ const runPane = (name: string) =>
onInputJsonChange={noop}
onRun={noop}
onShowDetails={noop}
onUserIdChange={noop}
script={scriptNamed(name)}
userId="user"
/>
Expand Down Expand Up @@ -193,7 +192,6 @@ test("run panel reports the outcome, the calls and the drift", () => {
<RunPanel
inputJson="{}"
onInputJsonChange={noop}
onUserIdChange={noop}
report={successfulRun}
userId="user"
/>
Expand Down
4 changes: 0 additions & 4 deletions apps/frontend/src/components/scripts/run-panel.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ const meta = {
inputJson:
'{\n "owner": "arcadeai",\n "repo": "arcade-ai",\n "number": 481\n}\n',
onInputJsonChange: noop,
onUserIdChange: noop,
userId: "anirudh@arcade.dev",
},
decorators: [
Expand All @@ -31,16 +30,13 @@ type Story = StoryObj<typeof meta>

export const Empty: Story = {
render: function EmptyStory(args) {
const [userId, setUserId] = useState(args.userId)
const [inputJson, setInputJson] = useState(args.inputJson)

return (
<RunPanel
{...args}
inputJson={inputJson}
onInputJsonChange={setInputJson}
onUserIdChange={setUserId}
userId={userId}
/>
)
},
Expand Down
44 changes: 31 additions & 13 deletions apps/frontend/src/components/scripts/run-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
/**
* What you fill in to run a script, and what comes back.
*
* The user id is a field rather than a setting because tools execute as a named
* end user with that user's authorizations — never as the deployment — so it is
* part of the run, not of the app. There is no dry-run: a plausible value
* generated from a declared shape only proves the shape was declared.
* The end user is shown rather than edited: tools execute as that named
* account with their authorizations — never as the deployment — and a signed-in
* session locks the field to the user's email. A 401 with an authorization URL
* is shown here the same way tool grants surface an Authorize link. There is no
* dry-run: a plausible value generated from a declared shape only proves the
* shape was declared.
*/
import { UserIcon } from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Field, FieldDescription, FieldLabel } from "@/components/ui/field"
import {
InputGroup,
Expand All @@ -20,19 +23,19 @@ import type { RunReportView } from "./types"

function RunPanel({
userId,
onUserIdChange,
inputJson,
onInputJsonChange,
disabled = false,
error = null,
authorizationUrl = null,
report = null,
}: {
userId: string
onUserIdChange: (userId: string) => void
inputJson: string
onInputJsonChange: (inputJson: string) => void
disabled?: boolean
error?: string | null
authorizationUrl?: string | null
report?: RunReportView | null
}) {
return (
Expand All @@ -44,17 +47,15 @@ function RunPanel({
<UserIcon />
</InputGroupAddon>
<InputGroupInput
disabled={disabled}
disabled
id="run-user"
onChange={(event) => onUserIdChange(event.target.value)}
placeholder="user@example.com"
value={userId}
/>
</InputGroup>
<FieldDescription>
The Arcade end user. Signed-in sessions fill this from your OIDC
account id; otherwise type an email or UUID. Every tool call is
bounded by what they could already do themselves.
The Arcade end user. Filled from your signed-in email. Every tool call
is bounded by what they could already do themselves.
</FieldDescription>
</Field>

Expand All @@ -70,8 +71,25 @@ function RunPanel({

{error ? (
<Alert variant="destructive">
<AlertTitle>Could not run</AlertTitle>
<AlertDescription>{error}</AlertDescription>
<AlertTitle>
{authorizationUrl ? "Sign in required" : "Could not run"}
</AlertTitle>
<AlertDescription className="flex flex-col gap-3">
<span>{error}</span>
{authorizationUrl ? (
<Button
className="w-fit"
nativeButton={false}
render={
<a href={authorizationUrl} rel="noreferrer" target="_blank" />
}
size="sm"
variant="outline"
>
Sign in
</Button>
) : null}
</AlertDescription>
</Alert>
) : null}

Expand Down
6 changes: 3 additions & 3 deletions apps/frontend/src/components/scripts/script-panes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,23 +124,23 @@ function BrowsePane({
function ScriptRunPane({
script,
userId,
onUserIdChange,
inputJson,
onInputJsonChange,
running = false,
error = null,
authorizationUrl = null,
report = null,
onRun,
onShowDetails,
onDelete,
}: {
script: ScriptView
userId: string
onUserIdChange: (userId: string) => void
inputJson: string
onInputJsonChange: (inputJson: string) => void
running?: boolean
error?: string | null
authorizationUrl?: string | null
report?: RunReportView | null
onRun: () => void
onShowDetails: () => void
Expand Down Expand Up @@ -183,11 +183,11 @@ function ScriptRunPane({
</span>
</div>
<RunPanel
authorizationUrl={authorizationUrl}
disabled={running}
error={error}
inputJson={inputJson}
onInputJsonChange={onInputJsonChange}
onUserIdChange={onUserIdChange}
report={report}
userId={userId}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ const meta = {
onInputJsonChange: noop,
onRun: noop,
onShowDetails: noop,
onUserIdChange: noop,
script: summarizeIssue,
userId: "anirudh@arcade.dev",
},
Expand All @@ -50,16 +49,13 @@ type Story = StoryObj<typeof meta>
/** Editable, so the input's JSON badge reacts to typing. */
export const Ready: Story = {
render: function ReadyStory(args) {
const [userId, setUserId] = useState(args.userId)
const [inputJson, setInputJson] = useState(args.inputJson)

return (
<ScriptRunPane
{...args}
inputJson={inputJson}
onInputJsonChange={setInputJson}
onUserIdChange={setUserId}
userId={userId}
/>
)
},
Expand Down
28 changes: 18 additions & 10 deletions apps/frontend/src/components/scripts/script-screen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,36 @@
* report next to this one's input. The input starts from the schema's declared
* `default`s, with type-shaped placeholders for any required field that lacks
* one — so Run always has a complete payload to edit. The user id is the
* exception and lives in an atom: it is the same person whichever script they run.
* exception and lives in an atom: filled from the signed-in email when present.
* An unauthenticated run is allowed to hit the API; a 401 returns an
* authorization URL to open.
*/

import { useAtom } from "jotai"
import { useAtomValue } from "jotai"
import { useState } from "react"
import { userIdAtom } from "@/atoms"
import { useRunScript } from "@/hooks/api"
import { AuthRecoveryError, useRunScript } from "@/hooks/api"
import { useScriptActions } from "@/hooks/script-actions"
import { defaultInputJson } from "./default-input"
import { ScriptDetailsSheet } from "./script-detail"
import { ScriptRunPane } from "./script-panes"
import type { RunReportView, ScriptView } from "./types"

export function ScriptScreen({ script }: { script: ScriptView }) {
const [userId, setUserId] = useAtom(userIdAtom)
const userId = useAtomValue(userIdAtom)
const [inputJson, setInputJson] = useState(() =>
defaultInputJson(script.input)
)
const [error, setError] = useState<string | null>(null)
const [authorizationUrl, setAuthorizationUrl] = useState<string | null>(null)
const [report, setReport] = useState<RunReportView | null>(null)
const [detailsOpen, setDetailsOpen] = useState(false)
const { openDelete } = useScriptActions()
const run = useRunScript()

const onRun = async () => {
setError(null)
setAuthorizationUrl(null)
setReport(null)
let input: unknown
try {
Expand All @@ -40,32 +44,36 @@ export function ScriptScreen({ script }: { script: ScriptView }) {
setError("Input must be valid JSON")
return
}
if (!userId.trim()) {
setError("A user id is required — tools run as a named end user")
return
}
try {
setReport(
await run.mutateAsync({
name: script.name,
body: { input, userId: userId.trim() },
// Body `userId` is ignored once a session exists; a placeholder keeps
// the request valid so an unauthenticated run can still receive a 401
// with an authorization URL.
body: { input, userId: userId.trim() || "unauthenticated" },
})
)
} catch (err) {
if (err instanceof AuthRecoveryError) {
setError(err.message)
setAuthorizationUrl(err.authorizationUrl)
return
}
setError(err instanceof Error ? err.message : String(err))
}
}

return (
<>
<ScriptRunPane
authorizationUrl={authorizationUrl}
error={error}
inputJson={inputJson}
onDelete={openDelete}
onInputJsonChange={setInputJson}
onRun={() => void onRun()}
onShowDetails={() => setDetailsOpen(true)}
onUserIdChange={setUserId}
report={report}
running={run.isPending}
script={script}
Expand Down
22 changes: 21 additions & 1 deletion apps/frontend/src/hooks/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,12 +109,32 @@ export type RunScriptBody = {

export type RunReport = Awaited<ReturnType<typeof fetchRun>>

/** 401 from run — open `authorizationUrl`, then retry. */
export class AuthRecoveryError extends Error {
readonly authorizationUrl: string

constructor(message: string, authorizationUrl: string) {
super(message)
this.name = "AuthRecoveryError"
this.authorizationUrl = authorizationUrl
}
}

async function fetchRun(name: string, body: RunScriptBody) {
const res = await api.scripts[":name"].run.$post({
param: { name },
json: body,
})
if (res.status === 404 || res.status === 401) {
if (res.status === 401) {
const err = await res.json()
const url =
"authorizationUrl" in err && typeof err.authorizationUrl === "string"
? err.authorizationUrl
: null
if (url) throw new AuthRecoveryError(err.message, url)
throw new Error(err.message)
}
if (res.status === 404) {
const err = await res.json()
throw new Error(err.message)
}
Expand Down
4 changes: 2 additions & 2 deletions apps/frontend/src/lib/auth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,12 @@ export function signInWithOidc(callbackURL = "/") {
})
}

/** Arcade `user_id` from a Better Auth session user. */
/** Arcade `user_id` from a Better Auth session user: email, else OIDC `sub`. */
export function arcadeUserIdFromSession(user: {
accountId?: string | null
email?: string | null
}): string | null {
if (user.accountId) return user.accountId
if (user.email) return user.email
if (user.accountId) return user.accountId
return null
}
Loading