summaryrefslogtreecommitdiffhomepage
path: root/cloud/app
diff options
context:
space:
mode:
Diffstat (limited to 'cloud/app')
-rw-r--r--cloud/app/src/component/workspace/billing-section.tsx153
-rw-r--r--cloud/app/src/component/workspace/common.tsx27
-rw-r--r--cloud/app/src/component/workspace/key-section.tsx181
-rw-r--r--cloud/app/src/component/workspace/monthly-limit-section.tsx129
-rw-r--r--cloud/app/src/component/workspace/new-user-section.tsx97
-rw-r--r--cloud/app/src/component/workspace/payment-section.tsx56
-rw-r--r--cloud/app/src/component/workspace/usage-section.tsx66
-rw-r--r--cloud/app/src/routes/workspace/[id].tsx669
8 files changed, 717 insertions, 661 deletions
diff --git a/cloud/app/src/component/workspace/billing-section.tsx b/cloud/app/src/component/workspace/billing-section.tsx
new file mode 100644
index 000000000..4bc4d4225
--- /dev/null
+++ b/cloud/app/src/component/workspace/billing-section.tsx
@@ -0,0 +1,153 @@
+import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
+import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
+import { Billing } from "@opencode/cloud-core/billing.js"
+import { withActor } from "~/context/auth.withActor"
+import { IconCreditCard } from "~/component/icon"
+
+const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
+ "use server"
+ return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
+}, "checkoutUrl")
+
+const reload = action(async (form: FormData) => {
+ "use server"
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ return json(await withActor(() => Billing.reload(), workspaceID), { revalidate: getBillingInfo.key })
+}, "billing.reload")
+
+const disableReload = action(async (form: FormData) => {
+ "use server"
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ return json(await withActor(() => Billing.disableReload(), workspaceID), { revalidate: getBillingInfo.key })
+}, "billing.disableReload")
+
+const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
+ "use server"
+ return withActor(() => Billing.generateSessionUrl({ returnUrl }), workspaceID)
+}, "sessionUrl")
+
+const getBillingInfo = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ return await Billing.get()
+ }, workspaceID)
+}, "billing.get")
+
+export function BillingSection() {
+ const params = useParams()
+ const balanceInfo = createAsync(() => getBillingInfo(params.id))
+ const createCheckoutUrlAction = useAction(createCheckoutUrl)
+ const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
+ const createSessionUrlAction = useAction(createSessionUrl)
+ const createSessionUrlSubmission = useSubmission(createSessionUrl)
+ const disableReloadSubmission = useSubmission(disableReload)
+ const reloadSubmission = useSubmission(reload)
+
+ const balanceAmount = createMemo(() => {
+ return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
+ })
+
+ return (
+ <section data-component="billing-section">
+ <div data-slot="section-title">
+ <h2>Billing</h2>
+ <p>
+ Manage payments methods. <a href="mailto:[email protected]">Contact us</a> if you have any questions.
+ </p>
+ </div>
+ <div data-slot="section-content">
+ <Show when={balanceInfo()?.reloadError}>
+ <div data-slot="reload-error">
+ <p>
+ Reload failed at{" "}
+ {balanceInfo()?.timeReloadError!.toLocaleString("en-US", {
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ second: "2-digit",
+ })}
+ . Reason: {balanceInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and try
+ again.
+ </p>
+ <form action={reload} method="post" data-slot="create-form">
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <button data-color="primary" type="submit" disabled={reloadSubmission.pending}>
+ {reloadSubmission.pending ? "Reloading..." : "Reload"}
+ </button>
+ </form>
+ </div>
+ </Show>
+ <div data-slot="payment">
+ <div data-slot="credit-card">
+ <div data-slot="card-icon">
+ <IconCreditCard style={{ width: "32px", height: "32px" }} />
+ </div>
+ <div data-slot="card-details">
+ <Show when={balanceInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}>
+ <span data-slot="secret">••••</span>
+ <span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
+ </Show>
+ </div>
+ </div>
+ <div data-slot="button-row">
+ <Show
+ when={balanceInfo()?.reload}
+ fallback={
+ <button
+ data-color="primary"
+ disabled={createCheckoutUrlSubmission.pending}
+ onClick={async () => {
+ const baseUrl = window.location.href
+ const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
+ if (checkoutUrl) {
+ window.location.href = checkoutUrl
+ }
+ }}
+ >
+ {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
+ </button>
+ }
+ >
+ <button
+ data-color="primary"
+ disabled={createSessionUrlSubmission.pending}
+ onClick={async () => {
+ const baseUrl = window.location.href
+ const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
+ if (sessionUrl) {
+ window.location.href = sessionUrl
+ }
+ }}
+ >
+ {createSessionUrlSubmission.pending ? "Loading..." : "Manage Payment Methods"}
+ </button>
+ <form action={disableReload} method="post" data-slot="create-form">
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <button data-color="ghost" type="submit" disabled={disableReloadSubmission.pending}>
+ {disableReloadSubmission.pending ? "Disabling..." : "Disable"}
+ </button>
+ </form>
+ </Show>
+ </div>
+ </div>
+ <div data-slot="usage">
+ <Show when={!balanceInfo()?.reload && !(balanceAmount() === "0.00" || balanceAmount() === "-0.00")}>
+ <p>
+ You have <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b> remaining in
+ your account. You can continue using the API with your remaining balance.
+ </p>
+ </Show>
+ <Show when={balanceInfo()?.reload && !balanceInfo()?.reloadError}>
+ <p>
+ Your current balance is <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
+ . We'll automatically reload <b>$20</b> (+$1.23 processing fee) when it reaches <b>$5</b>.
+ </p>
+ </Show>
+ </div>
+ </div>
+ </section>
+ )
+}
diff --git a/cloud/app/src/component/workspace/common.tsx b/cloud/app/src/component/workspace/common.tsx
new file mode 100644
index 000000000..fd1b8b1b6
--- /dev/null
+++ b/cloud/app/src/component/workspace/common.tsx
@@ -0,0 +1,27 @@
+export function formatDateForTable(date: Date) {
+ const options: Intl.DateTimeFormatOptions = {
+ day: "numeric",
+ month: "short",
+ hour: "numeric",
+ minute: "2-digit",
+ hour12: true,
+ }
+ return date.toLocaleDateString("en-GB", options).replace(",", ",")
+}
+
+export function formatDateUTC(date: Date) {
+ const options: Intl.DateTimeFormatOptions = {
+ weekday: "short",
+ year: "numeric",
+ month: "short",
+ day: "numeric",
+ hour: "numeric",
+ minute: "2-digit",
+ second: "2-digit",
+ timeZoneName: "short",
+ timeZone: "UTC",
+ }
+ return date.toLocaleDateString("en-US", options)
+}
+
+
diff --git a/cloud/app/src/component/workspace/key-section.tsx b/cloud/app/src/component/workspace/key-section.tsx
new file mode 100644
index 000000000..ef1601567
--- /dev/null
+++ b/cloud/app/src/component/workspace/key-section.tsx
@@ -0,0 +1,181 @@
+import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
+import { createEffect, createSignal, For, Show } from "solid-js"
+import { IconCopy, IconCheck } from "~/component/icon"
+import { Key } from "@opencode/cloud-core/key.js"
+import { withActor } from "~/context/auth.withActor"
+import { createStore } from "solid-js/store"
+import { formatDateUTC, formatDateForTable } from "./common"
+
+const removeKey = action(async (form: FormData) => {
+ "use server"
+ const id = form.get("id")?.toString()
+ if (!id) return { error: "ID is required" }
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key })
+}, "key.remove")
+
+const createKey = action(async (form: FormData) => {
+ "use server"
+ const name = form.get("name")?.toString().trim()
+ if (!name) return { error: "Name is required" }
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ return json(
+ await withActor(
+ () =>
+ Key.create({ name })
+ .then((data) => ({ error: undefined, data }))
+ .catch((e) => ({ error: e.message as string })),
+ workspaceID,
+ ),
+ { revalidate: listKeys.key },
+ )
+}, "key.create")
+
+const listKeys = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(() => Key.list(), workspaceID)
+}, "key.list")
+
+export function KeyCreateForm() {
+ const params = useParams()
+ const submission = useSubmission(createKey)
+ const [store, setStore] = createStore({ show: false })
+
+ let input: HTMLInputElement
+
+ createEffect(() => {
+ if (!submission.pending && submission.result && !submission.result.error) {
+ hide()
+ }
+ })
+
+ function show() {
+ // submission.clear() does not clear the result in some cases, ie.
+ // 1. Create key with empty name => error shows
+ // 2. Put in a key name and creates the key => form hides
+ // 3. Click add key button again => form shows with the same error if
+ // submission.clear() is called only once
+ while (true) {
+ submission.clear()
+ if (!submission.result) break
+ }
+ setStore("show", true)
+ input.focus()
+ }
+
+ function hide() {
+ setStore("show", false)
+ }
+
+ return (
+ <Show
+ when={store.show}
+ fallback={
+ <button data-color="primary" onClick={() => show()}>
+ Create API Key
+ </button>
+ }
+ >
+ <form action={createKey} method="post" data-slot="create-form">
+ <div data-slot="input-container">
+ <input ref={(r) => (input = r)} data-component="input" name="name" type="text" placeholder="Enter key name" />
+ <Show when={submission.result && submission.result.error}>
+ {(err) => <div data-slot="form-error">{err()}</div>}
+ </Show>
+ </div>
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <div data-slot="form-actions">
+ <button type="reset" data-color="ghost" onClick={() => hide()}>
+ Cancel
+ </button>
+ <button type="submit" data-color="primary" disabled={submission.pending}>
+ {submission.pending ? "Creating..." : "Create"}
+ </button>
+ </div>
+ </form>
+ </Show>
+ )
+}
+
+export function KeySection() {
+ const params = useParams()
+ const keys = createAsync(() => listKeys(params.id))
+
+ function formatKey(key: string) {
+ if (key.length <= 11) return key
+ return `${key.slice(0, 7)}...${key.slice(-4)}`
+ }
+
+ return (
+ <section data-component="api-keys-section">
+ <div data-slot="section-title">
+ <h2>API Keys</h2>
+ <p>Manage your API keys for accessing opencode services.</p>
+ </div>
+ <KeyCreateForm />
+ <div data-slot="api-keys-table">
+ <Show
+ when={keys()?.length}
+ fallback={
+ <div data-component="empty-state">
+ <p>Create an opencode Gateway API key</p>
+ </div>
+ }
+ >
+ <table data-slot="api-keys-table-element">
+ <thead>
+ <tr>
+ <th>Name</th>
+ <th>Key</th>
+ <th>Created</th>
+ <th></th>
+ </tr>
+ </thead>
+ <tbody>
+ <For each={keys()!}>
+ {(key) => {
+ const [copied, setCopied] = createSignal(false)
+ // const submission = useSubmission(removeKey, ([fd]) => fd.get("id")?.toString() === key.id)
+ return (
+ <tr>
+ <td data-slot="key-name">{key.name}</td>
+ <td data-slot="key-value">
+ <button
+ data-color="ghost"
+ disabled={copied()}
+ onClick={async () => {
+ await navigator.clipboard.writeText(key.key)
+ setCopied(true)
+ setTimeout(() => setCopied(false), 1000)
+ }}
+ title="Copy API key"
+ >
+ <span>{formatKey(key.key)}</span>
+ <Show when={copied()} fallback={<IconCopy style={{ width: "14px", height: "14px" }} />}>
+ <IconCheck style={{ width: "14px", height: "14px" }} />
+ </Show>
+ </button>
+ </td>
+ <td data-slot="key-date" title={formatDateUTC(key.timeCreated)}>
+ {formatDateForTable(key.timeCreated)}
+ </td>
+ <td data-slot="key-actions">
+ <form action={removeKey} method="post">
+ <input type="hidden" name="id" value={key.id} />
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <button data-color="ghost">Delete</button>
+ </form>
+ </td>
+ </tr>
+ )
+ }}
+ </For>
+ </tbody>
+ </table>
+ </Show>
+ </div>
+ </section>
+ )
+}
diff --git a/cloud/app/src/component/workspace/monthly-limit-section.tsx b/cloud/app/src/component/workspace/monthly-limit-section.tsx
new file mode 100644
index 000000000..0ed454789
--- /dev/null
+++ b/cloud/app/src/component/workspace/monthly-limit-section.tsx
@@ -0,0 +1,129 @@
+import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router"
+import { createEffect, Show } from "solid-js"
+import { createStore } from "solid-js/store"
+import { withActor } from "~/context/auth.withActor"
+import { Billing } from "@opencode/cloud-core/billing.js"
+
+const getBillingInfo = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ return await Billing.get()
+ }, workspaceID)
+}, "billing.get")
+
+const setMonthlyLimit = action(async (form: FormData) => {
+ "use server"
+ const limit = form.get("limit")?.toString()
+ if (!limit) return { error: "Limit is required" }
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ return json(
+ await withActor(
+ () =>
+ Billing.setMonthlyLimit(parseInt(limit))
+ .then((data) => ({ error: undefined, data }))
+ .catch((e) => ({ error: e.message as string })),
+ workspaceID,
+ ),
+ { revalidate: getBillingInfo.key },
+ )
+}, "billing.setMonthlyLimit")
+
+export function MonthlyLimitSection() {
+ const params = useParams()
+ const submission = useSubmission(setMonthlyLimit)
+ const [store, setStore] = createStore({ show: false })
+ const balanceInfo = createAsync(() => getBillingInfo(params.id))
+
+ let input: HTMLInputElement
+
+ createEffect(() => {
+ if (!submission.pending && submission.result && !submission.result.error) {
+ hide()
+ }
+ })
+
+ function show() {
+ // submission.clear() does not clear the result in some cases, ie.
+ // 1. Create key with empty name => error shows
+ // 2. Put in a key name and creates the key => form hides
+ // 3. Click add key button again => form shows with the same error if
+ // submission.clear() is called only once
+ while (true) {
+ submission.clear()
+ if (!submission.result) break
+ }
+ setStore("show", true)
+ input.focus()
+ }
+
+ function hide() {
+ setStore("show", false)
+ }
+
+ return (
+ <section data-component="monthly-limit-section">
+ <div data-slot="section-title">
+ <h2>Monthly Limit</h2>
+ <p>Set a monthly spending limit for your account.</p>
+ </div>
+ <div data-slot="section-content">
+ <div data-slot="balance">
+ <div data-slot="amount">
+ {balanceInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
+ <span data-slot="value">{balanceInfo()?.monthlyLimit ?? "-"}</span>
+ </div>
+ <Show
+ when={!store.show}
+ fallback={
+ <form action={setMonthlyLimit} method="post" data-slot="create-form">
+ <div data-slot="input-container">
+ <input ref={(r) => (input = r)} data-component="input" name="limit" type="number" placeholder="50" />
+ <Show when={submission.result && submission.result.error}>
+ {(err) => <div data-slot="form-error">{err()}</div>}
+ </Show>
+ </div>
+ <input type="hidden" name="workspaceID" value={params.id} />
+ <div data-slot="form-actions">
+ <button type="reset" data-color="ghost" onClick={() => hide()}>
+ Cancel
+ </button>
+ <button type="submit" data-color="primary" disabled={submission.pending}>
+ {submission.pending ? "Setting..." : "Set"}
+ </button>
+ </div>
+ </form>
+ }
+ >
+ <button data-color="primary" onClick={() => show()}>
+ {balanceInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
+ </button>
+ </Show>
+ </div>
+ <Show when={balanceInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No spending limit set.</p>}>
+ <p data-slot="usage-status">
+ Current usage for {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $
+ {(() => {
+ const dateLastUsed = balanceInfo()?.timeMonthlyUsageUpdated
+ if (!dateLastUsed) return "0"
+
+ const current = new Date().toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ timeZone: "UTC",
+ })
+ const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
+ year: "numeric",
+ month: "long",
+ timeZone: "UTC",
+ })
+ if (current !== lastUsed) return "0"
+ return ((balanceInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
+ })()}
+ .
+ </p>
+ </Show>
+ </div>
+ </section>
+ )
+}
diff --git a/cloud/app/src/component/workspace/new-user-section.tsx b/cloud/app/src/component/workspace/new-user-section.tsx
new file mode 100644
index 000000000..ca38390b3
--- /dev/null
+++ b/cloud/app/src/component/workspace/new-user-section.tsx
@@ -0,0 +1,97 @@
+import { query, useParams, createAsync } from "@solidjs/router"
+import { createMemo, createSignal, Show } from "solid-js"
+import { IconCopy, IconCheck } from "~/component/icon"
+import { Key } from "@opencode/cloud-core/key.js"
+import { Billing } from "@opencode/cloud-core/billing.js"
+import { withActor } from "~/context/auth.withActor"
+
+const getUsageInfo = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ return await Billing.usages()
+ }, workspaceID)
+}, "usage.list")
+
+const listKeys = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(() => Key.list(), workspaceID)
+}, "key.list")
+
+export function NewUserSection() {
+ const params = useParams()
+ const [copiedKey, setCopiedKey] = createSignal(false)
+ const keys = createAsync(() => listKeys(params.id))
+ const usage = createAsync(() => getUsageInfo(params.id))
+ const isNew = createMemo(() => {
+ const keysList = keys()
+ const usageList = usage()
+ return keysList?.length === 1 && (!usageList || usageList.length === 0)
+ })
+ const defaultKey = createMemo(() => keys()?.at(-1)?.key)
+
+ return (
+ <Show when={isNew()}>
+ <div data-slot="new-user-sections">
+ <div data-component="feature-grid">
+ <div data-slot="feature">
+ <h3>Tested & Verified Models</h3>
+ <p>We've benchmarked and tested models specifically for coding agents to ensure the best performance.</p>
+ </div>
+ <div data-slot="feature">
+ <h3>Highest Quality</h3>
+ <p>Access models configured for optimal performance - no downgrades or routing to cheaper providers.</p>
+ </div>
+ <div data-slot="feature">
+ <h3>No Lock-in</h3>
+ <p>Use Zen with any coding agent, and continue using other providers with opencode whenever you want.</p>
+ </div>
+ </div>
+
+ <div data-component="api-key-highlight">
+ <Show when={defaultKey()}>
+ <div data-slot="key-display">
+ <div data-slot="key-container">
+ <code data-slot="key-value">{defaultKey()}</code>
+ <button
+ data-color="primary"
+ disabled={copiedKey()}
+ onClick={async () => {
+ await navigator.clipboard.writeText(defaultKey() ?? "")
+ setCopiedKey(true)
+ setTimeout(() => setCopiedKey(false), 2000)
+ }}
+ title="Copy API key"
+ >
+ <Show
+ when={copiedKey()}
+ fallback={
+ <>
+ <IconCopy style={{ width: "16px", height: "16px" }} /> Copy Key
+ </>
+ }
+ >
+ <IconCheck style={{ width: "16px", height: "16px" }} /> Copied!
+ </Show>
+ </button>
+ </div>
+ </div>
+ </Show>
+ </div>
+
+ <div data-component="next-steps">
+ <ol>
+ <li>Enable billing</li>
+ <li>
+ Run <code>opencode auth login</code> and select opencode
+ </li>
+ <li>Paste your API key</li>
+ <li>
+ Start opencode and run <code>/models</code> to select a model
+ </li>
+ </ol>
+ </div>
+ </div>
+ </Show>
+ )
+}
+
diff --git a/cloud/app/src/component/workspace/payment-section.tsx b/cloud/app/src/component/workspace/payment-section.tsx
new file mode 100644
index 000000000..f802fa96a
--- /dev/null
+++ b/cloud/app/src/component/workspace/payment-section.tsx
@@ -0,0 +1,56 @@
+import { Billing } from "@opencode/cloud-core/billing.js"
+import { query, useParams, createAsync } from "@solidjs/router"
+import { For } from "solid-js"
+import { withActor } from "~/context/auth.withActor"
+import { formatDateUTC, formatDateForTable } from "./common"
+
+const getPaymentsInfo = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ return await Billing.payments()
+ }, workspaceID)
+}, "payment.list")
+
+export function PaymentSection() {
+ const params = useParams()
+ const payments = createAsync(() => getPaymentsInfo(params.id))
+
+ return (
+ payments() &&
+ payments()!.length > 0 && (
+ <section data-component="payments-section">
+ <div data-slot="section-title">
+ <h2>Payments History</h2>
+ <p>Recent payment transactions.</p>
+ </div>
+ <div data-slot="payments-table">
+ <table data-slot="payments-table-element">
+ <thead>
+ <tr>
+ <th>Date</th>
+ <th>Payment ID</th>
+ <th>Amount</th>
+ </tr>
+ </thead>
+ <tbody>
+ <For each={payments()!}>
+ {(payment) => {
+ const date = new Date(payment.timeCreated)
+ return (
+ <tr>
+ <td data-slot="payment-date" title={formatDateUTC(date)}>
+ {formatDateForTable(date)}
+ </td>
+ <td data-slot="payment-id">{payment.id}</td>
+ <td data-slot="payment-amount">${((payment.amount ?? 0) / 100000000).toFixed(2)}</td>
+ </tr>
+ )
+ }}
+ </For>
+ </tbody>
+ </table>
+ </div>
+ </section>
+ )
+ )
+}
diff --git a/cloud/app/src/component/workspace/usage-section.tsx b/cloud/app/src/component/workspace/usage-section.tsx
new file mode 100644
index 000000000..a2ad507af
--- /dev/null
+++ b/cloud/app/src/component/workspace/usage-section.tsx
@@ -0,0 +1,66 @@
+import { Billing } from "@opencode/cloud-core/billing.js"
+import { query, useParams, createAsync } from "@solidjs/router"
+import { createMemo, For, Show } from "solid-js"
+import { formatDateUTC, formatDateForTable } from "./common"
+import { withActor } from "~/context/auth.withActor"
+
+const getUsageInfo = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ return await Billing.usages()
+ }, workspaceID)
+}, "usage.list")
+
+export function UsageSection() {
+ const params = useParams()
+ const usage = createAsync(() => getUsageInfo(params.id))
+
+ return (
+ <section data-component="usage-section">
+ <div data-slot="section-title">
+ <h2>Usage History</h2>
+ <p>Recent API usage and costs.</p>
+ </div>
+ <div data-slot="usage-table">
+ <Show
+ when={usage() && usage()!.length > 0}
+ fallback={
+ <div data-component="empty-state">
+ <p>Make your first API call to get started.</p>
+ </div>
+ }
+ >
+ <table data-slot="usage-table-element">
+ <thead>
+ <tr>
+ <th>Date</th>
+ <th>Model</th>
+ <th>Input</th>
+ <th>Output</th>
+ <th>Cost</th>
+ </tr>
+ </thead>
+ <tbody>
+ <For each={usage()!}>
+ {(usage) => {
+ const date = createMemo(() => new Date(usage.timeCreated))
+ return (
+ <tr>
+ <td data-slot="usage-date" title={formatDateUTC(date())}>
+ {formatDateForTable(date())}
+ </td>
+ <td data-slot="usage-model">{usage.model}</td>
+ <td data-slot="usage-tokens">{usage.inputTokens}</td>
+ <td data-slot="usage-tokens">{usage.outputTokens}</td>
+ <td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
+ </tr>
+ )
+ }}
+ </For>
+ </tbody>
+ </table>
+ </Show>
+ </div>
+ </section>
+ )
+}
diff --git a/cloud/app/src/routes/workspace/[id].tsx b/cloud/app/src/routes/workspace/[id].tsx
index ad107825a..ed1434a69 100644
--- a/cloud/app/src/routes/workspace/[id].tsx
+++ b/cloud/app/src/routes/workspace/[id].tsx
@@ -1,77 +1,14 @@
import "./[id].css"
import { Billing } from "@opencode/cloud-core/billing.js"
-import { Key } from "@opencode/cloud-core/key.js"
-import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
-import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
+import { query, useParams, createAsync } from "@solidjs/router"
+import { Show } from "solid-js"
import { withActor } from "~/context/auth.withActor"
-import { IconCopy, IconCheck, IconCreditCard } from "~/component/icon"
-import { createStore } from "solid-js/store"
-
-function formatDateForTable(date: Date) {
- const options: Intl.DateTimeFormatOptions = {
- day: "numeric",
- month: "short",
- hour: "numeric",
- minute: "2-digit",
- hour12: true,
- }
- return date.toLocaleDateString("en-GB", options).replace(",", ",")
-}
-
-function formatDateUTC(date: Date) {
- const options: Intl.DateTimeFormatOptions = {
- weekday: "short",
- year: "numeric",
- month: "short",
- day: "numeric",
- hour: "numeric",
- minute: "2-digit",
- second: "2-digit",
- timeZoneName: "short",
- timeZone: "UTC",
- }
- return date.toLocaleDateString("en-US", options)
-}
-
-/////////////////////////////////////
-// Keys related queries and actions
-/////////////////////////////////////
-
-const listKeys = query(async (workspaceID: string) => {
- "use server"
- return withActor(() => Key.list(), workspaceID)
-}, "key.list")
-
-const createKey = action(async (form: FormData) => {
- "use server"
- const name = form.get("name")?.toString().trim()
- if (!name) return { error: "Name is required" }
- const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
- return json(
- await withActor(
- () =>
- Key.create({ name })
- .then((data) => ({ error: undefined, data }))
- .catch((e) => ({ error: e.message as string })),
- workspaceID,
- ),
- { revalidate: listKeys.key },
- )
-}, "key.create")
-
-const removeKey = action(async (form: FormData) => {
- "use server"
- const id = form.get("id")?.toString()
- if (!id) return { error: "ID is required" }
- const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
- return json(await withActor(() => Key.remove({ id }), workspaceID), { revalidate: listKeys.key })
-}, "key.remove")
-
-/////////////////////////////////////
-// Billing related queries and actions
-/////////////////////////////////////
+import { MonthlyLimitSection } from "~/component/workspace/monthly-limit-section"
+import { NewUserSection } from "~/component/workspace/new-user-section"
+import { BillingSection } from "~/component/workspace/billing-section"
+import { PaymentSection } from "~/component/workspace/payment-section"
+import { UsageSection } from "~/component/workspace/usage-section"
+import { KeySection } from "~/component/workspace/key-section"
const getBillingInfo = query(async (workspaceID: string) => {
"use server"
@@ -80,596 +17,6 @@ const getBillingInfo = query(async (workspaceID: string) => {
}, workspaceID)
}, "billing.get")
-const getUsageInfo = query(async (workspaceID: string) => {
- "use server"
- return withActor(async () => {
- return await Billing.usages()
- }, workspaceID)
-}, "usage.list")
-
-const getPaymentsInfo = query(async (workspaceID: string) => {
- "use server"
- return withActor(async () => {
- return await Billing.payments()
- }, workspaceID)
-}, "payment.list")
-
-const setMonthlyLimit = action(async (form: FormData) => {
- "use server"
- const limit = form.get("limit")?.toString()
- if (!limit) return { error: "Limit is required" }
- const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
- return json(
- await withActor(
- () =>
- Billing.setMonthlyLimit(parseInt(limit))
- .then((data) => ({ error: undefined, data }))
- .catch((e) => ({ error: e.message as string })),
- workspaceID,
- ),
- { revalidate: getBillingInfo.key },
- )
-}, "billing.setMonthlyLimit")
-
-const reload = action(async (form: FormData) => {
- "use server"
- const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
- return json(await withActor(() => Billing.reload(), workspaceID), { revalidate: getBillingInfo.key })
-}, "billing.reload")
-
-const disableReload = action(async (form: FormData) => {
- "use server"
- const workspaceID = form.get("workspaceID")?.toString()
- if (!workspaceID) return { error: "Workspace ID is required" }
- return json(await withActor(() => Billing.disableReload(), workspaceID), { revalidate: getBillingInfo.key })
-}, "billing.disableReload")
-
-const createCheckoutUrl = action(async (workspaceID: string, successUrl: string, cancelUrl: string) => {
- "use server"
- return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }), workspaceID)
-}, "checkoutUrl")
-
-const createSessionUrl = action(async (workspaceID: string, returnUrl: string) => {
- "use server"
- return withActor(() => Billing.generateSessionUrl({ returnUrl }), workspaceID)
-}, "sessionUrl")
-
-function KeySection() {
- const params = useParams()
- const keys = createAsync(() => listKeys(params.id))
-
- function formatKey(key: string) {
- if (key.length <= 11) return key
- return `${key.slice(0, 7)}...${key.slice(-4)}`
- }
-
- return (
- <section data-component="api-keys-section">
- <div data-slot="section-title">
- <h2>API Keys</h2>
- <p>Manage your API keys for accessing opencode services.</p>
- </div>
- <KeyCreateForm />
- <div data-slot="api-keys-table">
- <Show
- when={keys()?.length}
- fallback={
- <div data-component="empty-state">
- <p>Create an opencode Gateway API key</p>
- </div>
- }
- >
- <table data-slot="api-keys-table-element">
- <thead>
- <tr>
- <th>Name</th>
- <th>Key</th>
- <th>Created</th>
- <th></th>
- </tr>
- </thead>
- <tbody>
- <For each={keys()!}>
- {(key) => {
- const [copied, setCopied] = createSignal(false)
- // const submission = useSubmission(removeKey, ([fd]) => fd.get("id")?.toString() === key.id)
- return (
- <tr>
- <td data-slot="key-name">{key.name}</td>
- <td data-slot="key-value">
- <button
- data-color="ghost"
- disabled={copied()}
- onClick={async () => {
- await navigator.clipboard.writeText(key.key)
- setCopied(true)
- setTimeout(() => setCopied(false), 1000)
- }}
- title="Copy API key"
- >
- <span>{formatKey(key.key)}</span>
- <Show when={copied()} fallback={<IconCopy style={{ width: "14px", height: "14px" }} />}>
- <IconCheck style={{ width: "14px", height: "14px" }} />
- </Show>
- </button>
- </td>
- <td data-slot="key-date" title={formatDateUTC(key.timeCreated)}>
- {formatDateForTable(key.timeCreated)}
- </td>
- <td data-slot="key-actions">
- <form action={removeKey} method="post">
- <input type="hidden" name="id" value={key.id} />
- <input type="hidden" name="workspaceID" value={params.id} />
- <button data-color="ghost">Delete</button>
- </form>
- </td>
- </tr>
- )
- }}
- </For>
- </tbody>
- </table>
- </Show>
- </div>
- </section>
- )
-}
-
-function KeyCreateForm() {
- const params = useParams()
- const submission = useSubmission(createKey)
- const [store, setStore] = createStore({ show: false })
-
- let input: HTMLInputElement
-
- createEffect(() => {
- if (!submission.pending && submission.result && !submission.result.error) {
- hide()
- }
- })
-
- function show() {
- // submission.clear() does not clear the result in some cases, ie.
- // 1. Create key with empty name => error shows
- // 2. Put in a key name and creates the key => form hides
- // 3. Click add key button again => form shows with the same error if
- // submission.clear() is called only once
- while (true) {
- submission.clear()
- if (!submission.result) break
- }
- setStore("show", true)
- input.focus()
- }
-
- function hide() {
- setStore("show", false)
- }
-
- return (
- <Show
- when={store.show}
- fallback={
- <button data-color="primary" onClick={() => show()}>
- Create API Key
- </button>
- }
- >
- <form action={createKey} method="post" data-slot="create-form">
- <div data-slot="input-container">
- <input ref={(r) => (input = r)} data-component="input" name="name" type="text" placeholder="Enter key name" />
- <Show when={submission.result && submission.result.error}>
- {(err) => <div data-slot="form-error">{err()}</div>}
- </Show>
- </div>
- <input type="hidden" name="workspaceID" value={params.id} />
- <div data-slot="form-actions">
- <button type="reset" data-color="ghost" onClick={() => hide()}>
- Cancel
- </button>
- <button type="submit" data-color="primary" disabled={submission.pending}>
- {submission.pending ? "Creating..." : "Create"}
- </button>
- </div>
- </form>
- </Show>
- )
-}
-
-function BillingSection() {
- const params = useParams()
- const balanceInfo = createAsync(() => getBillingInfo(params.id))
- const createCheckoutUrlAction = useAction(createCheckoutUrl)
- const createCheckoutUrlSubmission = useSubmission(createCheckoutUrl)
- const createSessionUrlAction = useAction(createSessionUrl)
- const createSessionUrlSubmission = useSubmission(createSessionUrl)
- const disableReloadSubmission = useSubmission(disableReload)
- const reloadSubmission = useSubmission(reload)
-
- const balanceAmount = createMemo(() => {
- return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
- })
-
- return (
- <section data-component="billing-section">
- <div data-slot="section-title">
- <h2>Billing</h2>
- <p>
- Manage payments methods. <a href="mailto:[email protected]">Contact us</a> if you have any questions.
- </p>
- </div>
- <div data-slot="section-content">
- <Show when={balanceInfo()?.reloadError}>
- <div data-slot="reload-error">
- <p>
- Reload failed at{" "}
- {balanceInfo()?.timeReloadError!.toLocaleString("en-US", {
- month: "short",
- day: "numeric",
- hour: "numeric",
- minute: "2-digit",
- second: "2-digit",
- })}
- . Reason: {balanceInfo()?.reloadError?.replace(/\.$/, "")}. Please update your payment method and try
- again.
- </p>
- <form action={reload} method="post" data-slot="create-form">
- <input type="hidden" name="workspaceID" value={params.id} />
- <button data-color="primary" type="submit" disabled={reloadSubmission.pending}>
- {reloadSubmission.pending ? "Reloading..." : "Reload"}
- </button>
- </form>
- </div>
- </Show>
- <div data-slot="payment">
- <div data-slot="credit-card">
- <div data-slot="card-icon">
- <IconCreditCard style={{ width: "32px", height: "32px" }} />
- </div>
- <div data-slot="card-details">
- <Show when={balanceInfo()?.paymentMethodLast4} fallback={<span data-slot="number">----</span>}>
- <span data-slot="secret">••••</span>
- <span data-slot="number">{balanceInfo()?.paymentMethodLast4}</span>
- </Show>
- </div>
- </div>
- <div data-slot="button-row">
- <Show
- when={balanceInfo()?.reload}
- fallback={
- <button
- data-color="primary"
- disabled={createCheckoutUrlSubmission.pending}
- onClick={async () => {
- const baseUrl = window.location.href
- const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl)
- if (checkoutUrl) {
- window.location.href = checkoutUrl
- }
- }}
- >
- {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"}
- </button>
- }
- >
- <button
- data-color="primary"
- disabled={createSessionUrlSubmission.pending}
- onClick={async () => {
- const baseUrl = window.location.href
- const sessionUrl = await createSessionUrlAction(params.id, baseUrl)
- if (sessionUrl) {
- window.location.href = sessionUrl
- }
- }}
- >
- {createSessionUrlSubmission.pending ? "Loading..." : "Manage Payment Methods"}
- </button>
- <form action={disableReload} method="post" data-slot="create-form">
- <input type="hidden" name="workspaceID" value={params.id} />
- <button data-color="ghost" type="submit" disabled={disableReloadSubmission.pending}>
- {disableReloadSubmission.pending ? "Disabling..." : "Disable"}
- </button>
- </form>
- </Show>
- </div>
- </div>
- <div data-slot="usage">
- <Show when={!balanceInfo()?.reload && !(balanceAmount() === "0.00" || balanceAmount() === "-0.00")}>
- <p>
- You have <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b> remaining in
- your account. You can continue using the API with your remaining balance.
- </p>
- </Show>
- <Show when={balanceInfo()?.reload && !balanceInfo()?.reloadError}>
- <p>
- Your current balance is <b data-slot="value">${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()}</b>
- . We'll automatically reload <b>$20</b> (+$1.23 processing fee) when it reaches <b>$5</b>.
- </p>
- </Show>
- </div>
- </div>
- </section>
- )
-}
-
-function MonthlyLimitSection() {
- const params = useParams()
- const submission = useSubmission(setMonthlyLimit)
- const [store, setStore] = createStore({ show: false })
- const balanceInfo = createAsync(() => getBillingInfo(params.id))
-
- let input: HTMLInputElement
-
- createEffect(() => {
- if (!submission.pending && submission.result && !submission.result.error) {
- hide()
- }
- })
-
- function show() {
- // submission.clear() does not clear the result in some cases, ie.
- // 1. Create key with empty name => error shows
- // 2. Put in a key name and creates the key => form hides
- // 3. Click add key button again => form shows with the same error if
- // submission.clear() is called only once
- while (true) {
- submission.clear()
- if (!submission.result) break
- }
- setStore("show", true)
- input.focus()
- }
-
- function hide() {
- setStore("show", false)
- }
-
- return (
- <section data-component="monthly-limit-section">
- <div data-slot="section-title">
- <h2>Monthly Limit</h2>
- <p>Set a monthly spending limit for your account.</p>
- </div>
- <div data-slot="section-content">
- <div data-slot="balance">
- <div data-slot="amount">
- {balanceInfo()?.monthlyLimit ? <span data-slot="currency">$</span> : null}
- <span data-slot="value">{balanceInfo()?.monthlyLimit ?? "-"}</span>
- </div>
- <Show
- when={!store.show}
- fallback={
- <form action={setMonthlyLimit} method="post" data-slot="create-form">
- <div data-slot="input-container">
- <input ref={(r) => (input = r)} data-component="input" name="limit" type="number" placeholder="50" />
- <Show when={submission.result && submission.result.error}>
- {(err) => <div data-slot="form-error">{err()}</div>}
- </Show>
- </div>
- <input type="hidden" name="workspaceID" value={params.id} />
- <div data-slot="form-actions">
- <button type="reset" data-color="ghost" onClick={() => hide()}>
- Cancel
- </button>
- <button type="submit" data-color="primary" disabled={submission.pending}>
- {submission.pending ? "Setting..." : "Set"}
- </button>
- </div>
- </form>
- }
- >
- <button data-color="primary" onClick={() => show()}>
- {balanceInfo()?.monthlyLimit ? "Edit Limit" : "Set Limit"}
- </button>
- </Show>
- </div>
- <Show when={balanceInfo()?.monthlyLimit} fallback={<p data-slot="usage-status">No spending limit set.</p>}>
- <p data-slot="usage-status">
- Current usage for {new Date().toLocaleDateString("en-US", { month: "long", timeZone: "UTC" })} is $
- {(() => {
- const dateLastUsed = balanceInfo()?.timeMonthlyUsageUpdated
- if (!dateLastUsed) return "0"
-
- const current = new Date().toLocaleDateString("en-US", {
- year: "numeric",
- month: "long",
- timeZone: "UTC",
- })
- const lastUsed = dateLastUsed.toLocaleDateString("en-US", {
- year: "numeric",
- month: "long",
- timeZone: "UTC",
- })
- if (current !== lastUsed) return "0"
- return ((balanceInfo()?.monthlyUsage ?? 0) / 100000000).toFixed(2)
- })()}
- .
- </p>
- </Show>
- </div>
- </section>
- )
-}
-
-function UsageSection() {
- const params = useParams()
- const usage = createAsync(() => getUsageInfo(params.id))
-
- return (
- <section data-component="usage-section">
- <div data-slot="section-title">
- <h2>Usage History</h2>
- <p>Recent API usage and costs.</p>
- </div>
- <div data-slot="usage-table">
- <Show
- when={usage() && usage()!.length > 0}
- fallback={
- <div data-component="empty-state">
- <p>Make your first API call to get started.</p>
- </div>
- }
- >
- <table data-slot="usage-table-element">
- <thead>
- <tr>
- <th>Date</th>
- <th>Model</th>
- <th>Input</th>
- <th>Output</th>
- <th>Cost</th>
- </tr>
- </thead>
- <tbody>
- <For each={usage()!}>
- {(usage) => {
- const date = createMemo(() => new Date(usage.timeCreated))
- return (
- <tr>
- <td data-slot="usage-date" title={formatDateUTC(date())}>
- {formatDateForTable(date())}
- </td>
- <td data-slot="usage-model">{usage.model}</td>
- <td data-slot="usage-tokens">{usage.inputTokens}</td>
- <td data-slot="usage-tokens">{usage.outputTokens}</td>
- <td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
- </tr>
- )
- }}
- </For>
- </tbody>
- </table>
- </Show>
- </div>
- </section>
- )
-}
-
-function PaymentSection() {
- const params = useParams()
- const payments = createAsync(() => getPaymentsInfo(params.id))
-
- return (
- payments() &&
- payments()!.length > 0 && (
- <section data-component="payments-section">
- <div data-slot="section-title">
- <h2>Payments History</h2>
- <p>Recent payment transactions.</p>
- </div>
- <div data-slot="payments-table">
- <table data-slot="payments-table-element">
- <thead>
- <tr>
- <th>Date</th>
- <th>Payment ID</th>
- <th>Amount</th>
- </tr>
- </thead>
- <tbody>
- <For each={payments()!}>
- {(payment) => {
- const date = new Date(payment.timeCreated)
- return (
- <tr>
- <td data-slot="payment-date" title={formatDateUTC(date)}>
- {formatDateForTable(date)}
- </td>
- <td data-slot="payment-id">{payment.id}</td>
- <td data-slot="payment-amount">${((payment.amount ?? 0) / 100000000).toFixed(2)}</td>
- </tr>
- )
- }}
- </For>
- </tbody>
- </table>
- </div>
- </section>
- )
- )
-}
-
-function NewUserSection() {
- const params = useParams()
- const [copiedKey, setCopiedKey] = createSignal(false)
- const keys = createAsync(() => listKeys(params.id))
- const usage = createAsync(() => getUsageInfo(params.id))
- const isNew = createMemo(() => {
- const keysList = keys()
- const usageList = usage()
- return keysList?.length === 1 && (!usageList || usageList.length === 0)
- })
- const defaultKey = createMemo(() => keys()?.at(-1)?.key)
-
- return (
- <Show when={isNew()}>
- <div data-slot="new-user-sections">
- <div data-component="feature-grid">
- <div data-slot="feature">
- <h3>Tested & Verified Models</h3>
- <p>We've benchmarked and tested models specifically for coding agents to ensure the best performance.</p>
- </div>
- <div data-slot="feature">
- <h3>Highest Quality</h3>
- <p>Access models configured for optimal performance - no downgrades or routing to cheaper providers.</p>
- </div>
- <div data-slot="feature">
- <h3>No Lock-in</h3>
- <p>Use Zen with any coding agent, and continue using other providers with opencode whenever you want.</p>
- </div>
- </div>
-
- <div data-component="api-key-highlight">
- <Show when={defaultKey()}>
- <div data-slot="key-display">
- <div data-slot="key-container">
- <code data-slot="key-value">{defaultKey()}</code>
- <button
- data-color="primary"
- disabled={copiedKey()}
- onClick={async () => {
- await navigator.clipboard.writeText(defaultKey() ?? "")
- setCopiedKey(true)
- setTimeout(() => setCopiedKey(false), 2000)
- }}
- title="Copy API key"
- >
- <Show
- when={copiedKey()}
- fallback={
- <>
- <IconCopy style={{ width: "16px", height: "16px" }} /> Copy Key
- </>
- }
- >
- <IconCheck style={{ width: "16px", height: "16px" }} /> Copied!
- </Show>
- </button>
- </div>
- </div>
- </Show>
- </div>
-
- <div data-component="next-steps">
- <ol>
- <li>Enable billing</li>
- <li>
- Run <code>opencode auth login</code> and select opencode
- </li>
- <li>Paste your API key</li>
- <li>
- Start opencode and run <code>/models</code> to select a model
- </li>
- </ol>
- </div>
- </div>
- </Show>
- )
-}
-
export default function () {
const params = useParams()
const balanceInfo = createAsync(() => getBillingInfo(params.id))