summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app/src/component
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-09-18 10:59:01 -0400
committerFrank <[email protected]>2025-09-18 10:59:01 -0400
commit4ceabdffa07b1af8d99eb73622a4d549d99ec6d2 (patch)
tree72e2ae62084a9e24cc76caffbd1f30dafc69ea56 /packages/console/app/src/component
parentc87480cf931a6f8f8b55552558ef521f1918b578 (diff)
downloadopencode-4ceabdffa07b1af8d99eb73622a4d549d99ec6d2.tar.gz
opencode-4ceabdffa07b1af8d99eb73622a4d549d99ec6d2.zip
wip: zen
Diffstat (limited to 'packages/console/app/src/component')
-rw-r--r--packages/console/app/src/component/icon.tsx82
-rw-r--r--packages/console/app/src/component/workspace/billing-section.module.css114
-rw-r--r--packages/console/app/src/component/workspace/billing-section.tsx193
-rw-r--r--packages/console/app/src/component/workspace/common.tsx25
-rw-r--r--packages/console/app/src/component/workspace/key-section.module.css172
-rw-r--r--packages/console/app/src/component/workspace/key-section.tsx182
-rw-r--r--packages/console/app/src/component/workspace/monthly-limit-section.module.css102
-rw-r--r--packages/console/app/src/component/workspace/monthly-limit-section.tsx139
-rw-r--r--packages/console/app/src/component/workspace/new-user-section.module.css163
-rw-r--r--packages/console/app/src/component/workspace/new-user-section.tsx97
-rw-r--r--packages/console/app/src/component/workspace/payment-section.module.css72
-rw-r--r--packages/console/app/src/component/workspace/payment-section.tsx113
-rw-r--r--packages/console/app/src/component/workspace/usage-section.module.css88
-rw-r--r--packages/console/app/src/component/workspace/usage-section.tsx128
14 files changed, 1670 insertions, 0 deletions
diff --git a/packages/console/app/src/component/icon.tsx b/packages/console/app/src/component/icon.tsx
new file mode 100644
index 000000000..a82572e62
--- /dev/null
+++ b/packages/console/app/src/component/icon.tsx
@@ -0,0 +1,82 @@
+import { JSX } from "solid-js"
+
+export function IconLogo(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+ return (
+ <svg {...props} viewBox="0 0 289 50" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path d="M264.5 0H288.5V8.5H272.5V16.5H288.5V25H272.5V33H288.5V41.5H264.5V0Z" fill="currentColor" />
+ <path d="M248.5 0H224.5V41.5H248.5V33H232.5V8.5H248.5V0Z" fill="currentColor" />
+ <path d="M256.5 8.5H248.5V33H256.5V8.5Z" fill="currentColor" />
+ <path
+ fill-rule="evenodd"
+ clip-rule="evenodd"
+ d="M184.5 0H216.5V41.5H184.5V0ZM208.5 8.5H192.5V33H208.5V8.5Z"
+ fill="currentColor"
+ />
+ <path d="M144.5 8.5H136.5V41.5H144.5V8.5Z" fill="currentColor" />
+ <path d="M136.5 0H112.5V41.5H120.5V8.5H136.5V0Z" fill="currentColor" />
+ <path d="M80.5 0H104.5V8.5H88.5V16.5H104.5V25H88.5V33H104.5V41.5H80.5V0Z" fill="currentColor" />
+ <path
+ fill-rule="evenodd"
+ clip-rule="evenodd"
+ d="M40.5 0H72.5V41.5H48.5V49.5H40.5V0ZM64.5 8.5H48.5V33H64.5V8.5Z"
+ fill="currentColor"
+ />
+ <path
+ fill-rule="evenodd"
+ clip-rule="evenodd"
+ d="M0.5 0H32.5V41.5955H0.5V0ZM24.5 8.5H8.5V33H24.5V8.5Z"
+ fill="currentColor"
+ />
+ <path d="M152.5 0H176.5V8.5H160.5V33H176.5V41.5H152.5V0Z" fill="currentColor" />
+ </svg>
+ )
+}
+
+export function IconCopy(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+ return (
+ <svg {...props} viewBox="0 0 512 512">
+ <rect
+ width="336"
+ height="336"
+ x="128"
+ y="128"
+ fill="none"
+ stroke="currentColor"
+ stroke-linejoin="round"
+ stroke-width="32"
+ rx="57"
+ ry="57"
+ ></rect>
+ <path
+ fill="none"
+ stroke="currentColor"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ stroke-width="32"
+ d="m383.5 128l.5-24a56.16 56.16 0 0 0-56-56H112a64.19 64.19 0 0 0-64 64v216a56.16 56.16 0 0 0 56 56h24"
+ ></path>
+ </svg>
+ )
+}
+
+export function IconCheck(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+ return (
+ <svg {...props} viewBox="0 0 24 24">
+ <path
+ fill="currentColor"
+ d="M9 16.17L5.53 12.7a.996.996 0 1 0-1.41 1.41l4.18 4.18c.39.39 1.02.39 1.41 0L20.29 7.71a.996.996 0 1 0-1.41-1.41z"
+ ></path>
+ </svg>
+ )
+}
+
+export function IconCreditCard(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+ return (
+ <svg {...props} viewBox="0 0 24 24">
+ <path
+ fill="currentColor"
+ d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V8h16v10z"
+ />
+ </svg>
+ )
+}
diff --git a/packages/console/app/src/component/workspace/billing-section.module.css b/packages/console/app/src/component/workspace/billing-section.module.css
new file mode 100644
index 000000000..0bb5709cb
--- /dev/null
+++ b/packages/console/app/src/component/workspace/billing-section.module.css
@@ -0,0 +1,114 @@
+.root {
+ [data-slot="section-content"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ }
+
+ [data-slot="reload-error"] {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-4);
+ padding: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+
+ p {
+ color: var(--color-danger);
+ font-size: var(--font-size-sm);
+ line-height: 1.4;
+ margin: 0;
+ flex: 1;
+ }
+
+ [data-slot="create-form"] {
+ display: flex;
+ gap: var(--space-2);
+ margin: 0;
+ flex-shrink: 0;
+ }
+ }
+ [data-slot="payment"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ padding: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ min-width: 14.5rem;
+ width: fit-content;
+
+ @media (max-width: 30rem) {
+ width: 100%;
+ }
+
+ [data-slot="credit-card"] {
+ padding: var(--space-3-5) var(--space-4);
+ background-color: var(--color-bg-surface);
+ border-radius: var(--border-radius-sm);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+
+ [data-slot="card-icon"] {
+ display: flex;
+ align-items: center;
+ color: var(--color-text-muted);
+ }
+
+ [data-slot="card-details"] {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-1);
+
+ [data-slot="secret"] {
+ position: relative;
+ bottom: 2px;
+ font-size: var(--font-size-lg);
+ color: var(--color-text-muted);
+ font-weight: 400;
+ }
+
+ [data-slot="number"] {
+ font-size: var(--font-size-3xl);
+ font-weight: 500;
+ color: var(--color-text);
+ }
+ }
+ }
+
+ [data-slot="button-row"] {
+ display: flex;
+ gap: var(--space-2);
+ align-items: center;
+
+ @media (max-width: 30rem) {
+ flex-direction: column;
+
+ > button {
+ width: 100%;
+ }
+ }
+
+ [data-slot="create-form"] {
+ margin: 0;
+ }
+
+ /* Make Enable Billing button full width when it's the only button */
+ > button {
+ flex: 1;
+ }
+ }
+ }
+ [data-slot="usage"] {
+ p {
+ font-size: var(--font-size-sm);
+ line-height: 1.5;
+ color: var(--color-text-secondary);
+ b {
+ font-weight: 600;
+ }
+ }
+ }
+}
diff --git a/packages/console/app/src/component/workspace/billing-section.tsx b/packages/console/app/src/component/workspace/billing-section.tsx
new file mode 100644
index 000000000..57316e208
--- /dev/null
+++ b/packages/console/app/src/component/workspace/billing-section.tsx
@@ -0,0 +1,193 @@
+import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router"
+import { createMemo, Show } from "solid-js"
+import { Billing } from "@opencode/console-core/billing.js"
+import { withActor } from "~/context/auth.withActor"
+import { IconCreditCard } from "~/component/icon"
+import styles from "./billing-section.module.css"
+
+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()
+ // ORIGINAL CODE - COMMENTED OUT FOR TESTING
+ 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)
+
+ // DUMMY DATA FOR TESTING - UNCOMMENT ONE OF THE SCENARIOS BELOW
+
+ // Scenario 1: User has not added billing details and has no balance
+ // const balanceInfo = () => ({
+ // balance: 0,
+ // paymentMethodLast4: null as string | null,
+ // reload: false,
+ // reloadError: null as string | null,
+ // timeReloadError: null as Date | null,
+ // })
+
+ // Scenario 2: User has not added billing details but has a balance
+ // const balanceInfo = () => ({
+ // balance: 1500000000, // $15.00
+ // paymentMethodLast4: null as string | null,
+ // reload: false,
+ // reloadError: null as string | null,
+ // timeReloadError: null as Date | null
+ // })
+
+ // Scenario 3: User has added billing details (reload enabled)
+ // const balanceInfo = () => ({
+ // balance: 750000000, // $7.50
+ // paymentMethodLast4: "4242",
+ // reload: true,
+ // reloadError: null as string | null,
+ // timeReloadError: null as Date | null
+ // })
+
+ // Scenario 4: User has billing details but reload failed
+ // const balanceInfo = () => ({
+ // balance: 250000000, // $2.50
+ // paymentMethodLast4: "4242",
+ // reload: true,
+ // reloadError: "Your card was declined." as string,
+ // timeReloadError: new Date(Date.now() - 3600000) as Date // 1 hour ago
+ // })
+
+ const balanceAmount = createMemo(() => {
+ return ((balanceInfo()?.balance ?? 0) / 100000000).toFixed(2)
+ })
+
+ return (
+ <section class={styles.root}>
+ <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/packages/console/app/src/component/workspace/common.tsx b/packages/console/app/src/component/workspace/common.tsx
new file mode 100644
index 000000000..f85fd8423
--- /dev/null
+++ b/packages/console/app/src/component/workspace/common.tsx
@@ -0,0 +1,25 @@
+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/packages/console/app/src/component/workspace/key-section.module.css b/packages/console/app/src/component/workspace/key-section.module.css
new file mode 100644
index 000000000..6a1d0c85f
--- /dev/null
+++ b/packages/console/app/src/component/workspace/key-section.module.css
@@ -0,0 +1,172 @@
+.root {
+ [data-component="empty-state"] {
+ padding: var(--space-20) var(--space-6);
+ text-align: center;
+ border: 1px dashed var(--color-border);
+ border-radius: var(--border-radius-sm);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+
+ p {
+ line-height: 1.5;
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ }
+ }
+
+ [data-slot="create-form"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ padding: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+
+ [data-slot="input-container"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ }
+
+ @media (max-width: 30rem) {
+ gap: var(--space-2);
+ }
+
+ input {
+ flex: 1;
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ font-size: var(--font-size-sm);
+ font-family: var(--font-mono);
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ }
+
+ &::placeholder {
+ color: var(--color-text-disabled);
+ }
+ }
+
+ [data-slot="form-actions"] {
+ display: flex;
+ gap: var(--space-2);
+ }
+
+ [data-slot="form-error"] {
+ color: var(--color-danger);
+ font-size: var(--font-size-sm);
+ margin-top: var(--space-1);
+ line-height: 1.4;
+ }
+ }
+
+ [data-slot="api-keys-table"] {
+ overflow-x: auto;
+ }
+
+ [data-slot="api-keys-table-element"] {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--font-size-sm);
+
+ thead {
+ border-bottom: 1px solid var(--color-border);
+ }
+
+ th {
+ padding: var(--space-3) var(--space-4);
+ text-align: left;
+ font-weight: normal;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ }
+
+ td {
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--color-border-muted);
+ color: var(--color-text-muted);
+ font-family: var(--font-mono);
+
+ &[data-slot="key-name"] {
+ color: var(--color-text);
+ font-family: var(--font-sans);
+ font-weight: 500;
+ }
+
+ &[data-slot="key-value"] {
+ font-family: var(--font-mono);
+
+ button {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-size-sm);
+ font-weight: 400;
+ border: none;
+ background-color: transparent;
+ color: var(--color-text-muted);
+ font-family: var(--font-mono);
+ border-radius: var(--border-radius-sm);
+ cursor: pointer;
+ transition: all 0.15s ease;
+ text-transform: none;
+
+ &:hover:not(:disabled) {
+ background-color: var(--color-bg-surface);
+ color: var(--color-text);
+ }
+
+ &:disabled {
+ cursor: default;
+ color: var(--color-text);
+ }
+
+ span {
+ font-family: inherit;
+ }
+ }
+ }
+
+ &[data-slot="key-date"] {
+ color: var(--color-text);
+ }
+
+ &[data-slot="key-actions"] {
+ font-family: var(--font-sans);
+ }
+ }
+
+ tbody tr {
+ &:last-child td {
+ border-bottom: none;
+ }
+ }
+
+ @media (max-width: 40rem) {
+ th,
+ td {
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-size-xs);
+ }
+
+ th {
+ &:nth-child(3) /* Date */ {
+ display: none;
+ }
+ }
+
+ td {
+ &:nth-child(3) /* Date */ {
+ display: none;
+ }
+ }
+ }
+ }
+}
diff --git a/packages/console/app/src/component/workspace/key-section.tsx b/packages/console/app/src/component/workspace/key-section.tsx
new file mode 100644
index 000000000..a2bd380ea
--- /dev/null
+++ b/packages/console/app/src/component/workspace/key-section.tsx
@@ -0,0 +1,182 @@
+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/console-core/key.js"
+import { withActor } from "~/context/auth.withActor"
+import { createStore } from "solid-js/store"
+import { formatDateUTC, formatDateForTable } from "./common"
+import styles from "./key-section.module.css"
+
+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 class={styles.root}>
+ <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/packages/console/app/src/component/workspace/monthly-limit-section.module.css b/packages/console/app/src/component/workspace/monthly-limit-section.module.css
new file mode 100644
index 000000000..02de058e4
--- /dev/null
+++ b/packages/console/app/src/component/workspace/monthly-limit-section.module.css
@@ -0,0 +1,102 @@
+.root {
+ [data-slot="section-content"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ }
+
+ [data-slot="balance"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ padding: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ min-width: 15rem;
+ width: fit-content;
+
+ @media (max-width: 30rem) {
+ width: 100%;
+ }
+
+ [data-slot="amount"] {
+ padding: var(--space-3-5) var(--space-4);
+ background-color: var(--color-bg-surface);
+ border-radius: var(--border-radius-sm);
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-1);
+ justify-content: flex-end;
+
+ [data-slot="currency"] {
+ position: relative;
+ bottom: 2px;
+ font-size: var(--font-size-lg);
+ color: var(--color-text-muted);
+ font-weight: 400;
+ }
+
+ [data-slot="value"] {
+ font-size: var(--font-size-3xl);
+ font-weight: 500;
+ color: var(--color-text);
+ }
+ }
+
+ [data-slot="create-form"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+ margin-top: var(--space-1);
+
+ [data-slot="input-container"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+ }
+
+ @media (max-width: 30rem) {
+ gap: var(--space-2);
+ }
+
+ input {
+ flex: 1;
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ font-size: var(--font-size-sm);
+ font-family: var(--font-mono);
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-accent);
+ }
+
+ &::placeholder {
+ color: var(--color-text-disabled);
+ }
+ }
+
+ [data-slot="form-actions"] {
+ display: flex;
+ gap: var(--space-2);
+ justify-content: flex-end;
+ }
+
+ [data-slot="form-error"] {
+ color: var(--color-danger);
+ font-size: var(--font-size-sm);
+ line-height: 1.4;
+ }
+ }
+ }
+
+ [data-slot="usage-status"] {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ margin: 0;
+ line-height: 1.4;
+ }
+}
diff --git a/packages/console/app/src/component/workspace/monthly-limit-section.tsx b/packages/console/app/src/component/workspace/monthly-limit-section.tsx
new file mode 100644
index 000000000..35da774d0
--- /dev/null
+++ b/packages/console/app/src/component/workspace/monthly-limit-section.tsx
@@ -0,0 +1,139 @@
+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/console-core/billing.js"
+import styles from "./monthly-limit-section.module.css"
+
+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 numericLimit = parseInt(limit)
+ if (numericLimit < 0) return { error: "Set a valid monthly limit." }
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required." }
+ return json(
+ await withActor(
+ () =>
+ Billing.setMonthlyLimit(numericLimit)
+ .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 class={styles.root}>
+ <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
+ required
+ 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/packages/console/app/src/component/workspace/new-user-section.module.css b/packages/console/app/src/component/workspace/new-user-section.module.css
new file mode 100644
index 000000000..2edc7cc14
--- /dev/null
+++ b/packages/console/app/src/component/workspace/new-user-section.module.css
@@ -0,0 +1,163 @@
+.root {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-8);
+ padding: var(--space-6);
+ background-color: var(--color-bg-surface);
+ border: 1px dashed var(--color-border);
+ border-radius: var(--border-radius-sm);
+
+ @media (max-width: 30rem) {
+ gap: var(--space-8);
+ padding: var(--space-4);
+ }
+
+ [data-component="feature-grid"] {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+ gap: var(--space-6);
+
+ @media (max-width: 30rem) {
+ grid-template-columns: 1fr;
+ gap: var(--space-4);
+ }
+
+ [data-slot="feature"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ padding: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+
+ h3 {
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+ margin: 0;
+ color: var(--color-text);
+ text-transform: uppercase;
+ letter-spacing: -0.025rem;
+ }
+
+ p {
+ font-size: var(--font-size-sm);
+ line-height: 1.5;
+ margin: 0;
+ color: var(--color-text-muted);
+ }
+ }
+ }
+
+ [data-component="api-key-highlight"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+
+ [data-slot="section-title"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-1);
+
+ h2 {
+ font-size: var(--font-size-md);
+ font-weight: 600;
+ line-height: 1.2;
+ letter-spacing: -0.03125rem;
+ margin: 0;
+ color: var(--color-text-secondary);
+ text-transform: uppercase;
+
+ @media (max-width: 30rem) {
+ font-size: var(--font-size-md);
+ }
+ }
+ }
+
+ [data-slot="key-display"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-3);
+
+ [data-slot="key-container"] {
+ display: flex;
+ gap: var(--space-3);
+ padding: var(--space-4);
+ border: 2px solid var(--color-accent);
+ border-radius: var(--border-radius-sm);
+ align-items: center;
+
+ @media (max-width: 40rem) {
+ flex-direction: column;
+ gap: var(--space-3);
+ align-items: stretch;
+ }
+
+ [data-slot="key-value"] {
+ flex: 1;
+ font-family: var(--font-mono);
+ font-size: var(--font-size-sm);
+ color: var(--color-text);
+ background-color: var(--color-bg);
+ padding: var(--space-3);
+ border-radius: var(--border-radius-sm);
+ border: 1px solid var(--color-border);
+ word-break: break-all;
+ line-height: 1.4;
+
+ @media (max-width: 40rem) {
+ font-size: var(--font-size-xs);
+ padding: var(--space-2-5);
+ }
+ }
+
+ button {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-3) var(--space-4);
+ font-size: var(--font-size-sm);
+ font-weight: 500;
+ white-space: nowrap;
+ min-width: 130px;
+
+ @media (max-width: 40rem) {
+ justify-content: center;
+ padding: var(--space-2-5) var(--space-3);
+ font-size: var(--font-size-xs);
+ min-width: 96px;
+ }
+ }
+ }
+ }
+ }
+
+ [data-component="next-steps"] {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-6);
+
+ ol {
+ margin: 0;
+ padding-left: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+ list-style-position: inside;
+
+ li {
+ font-size: var(--font-size-md);
+ line-height: 1.5;
+ color: var(--color-text-secondary);
+
+ code {
+ font-family: var(--font-mono);
+ font-size: var(--font-size-sm);
+ padding: var(--space-1) var(--space-2);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ color: var(--color-text);
+ }
+ }
+ }
+ }
+}
diff --git a/packages/console/app/src/component/workspace/new-user-section.tsx b/packages/console/app/src/component/workspace/new-user-section.tsx
new file mode 100644
index 000000000..5909072dd
--- /dev/null
+++ b/packages/console/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/console-core/key.js"
+import { Billing } from "@opencode/console-core/billing.js"
+import { withActor } from "~/context/auth.withActor"
+import styles from "./new-user-section.module.css"
+
+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 class={styles.root}>
+ <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/packages/console/app/src/component/workspace/payment-section.module.css b/packages/console/app/src/component/workspace/payment-section.module.css
new file mode 100644
index 000000000..ea8e2ed42
--- /dev/null
+++ b/packages/console/app/src/component/workspace/payment-section.module.css
@@ -0,0 +1,72 @@
+.root {
+ [data-slot="payments-table"] {
+ overflow-x: auto;
+ }
+
+ [data-slot="payments-table-element"] {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--font-size-sm);
+
+ thead {
+ border-bottom: 1px solid var(--color-border);
+ }
+
+ th {
+ padding: var(--space-3) var(--space-4);
+ text-align: left;
+ font-weight: normal;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ }
+
+ td {
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--color-border-muted);
+ color: var(--color-text-muted);
+ font-family: var(--font-mono);
+
+ &[data-slot="payment-date"] {
+ color: var(--color-text);
+ }
+
+ &[data-slot="payment-id"] {
+ font-family: var(--font-mono);
+ font-weight: 400;
+ color: var(--color-text-muted);
+ max-width: 200px;
+ word-break: break-word;
+ }
+
+ &[data-slot="payment-amount"] {
+ color: var(--color-text);
+ }
+ }
+
+ tbody tr {
+ &:last-child td {
+ border-bottom: none;
+ }
+ }
+
+ @media (max-width: 40rem) {
+ th,
+ td {
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-size-xs);
+ }
+
+ th {
+ &:nth-child(2) /* Payment ID */ {
+ display: none;
+ }
+ }
+
+ td {
+ &:nth-child(2) /* Payment ID */ {
+ display: none;
+ }
+ }
+ }
+ }
+}
diff --git a/packages/console/app/src/component/workspace/payment-section.tsx b/packages/console/app/src/component/workspace/payment-section.tsx
new file mode 100644
index 000000000..7be51a581
--- /dev/null
+++ b/packages/console/app/src/component/workspace/payment-section.tsx
@@ -0,0 +1,113 @@
+import { Billing } from "@opencode/console-core/billing.js"
+import { query, action, useParams, createAsync, useAction } from "@solidjs/router"
+import { For } from "solid-js"
+import { withActor } from "~/context/auth.withActor"
+import { formatDateUTC, formatDateForTable } from "./common"
+import styles from "./payment-section.module.css"
+
+const getPaymentsInfo = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ return await Billing.payments()
+ }, workspaceID)
+}, "payment.list")
+
+const downloadReceipt = action(async (workspaceID: string, paymentID: string) => {
+ "use server"
+ return withActor(() => Billing.generateReceiptUrl({ paymentID }), workspaceID)
+}, "receipt.download")
+
+export function PaymentSection() {
+ const params = useParams()
+ // ORIGINAL CODE - COMMENTED OUT FOR TESTING
+ const payments = createAsync(() => getPaymentsInfo(params.id))
+ const downloadReceiptAction = useAction(downloadReceipt)
+
+ // DUMMY DATA FOR TESTING
+ // const payments = () => [
+ // {
+ // id: "pi_3QK1x2FT9vXn4A6r1234567890",
+ // paymentID: "pi_3QK1x2FT9vXn4A6r1234567890",
+ // timeCreated: new Date(Date.now() - 86400000 * 1).toISOString(), // 1 day ago
+ // amount: 2100000000, // $21.00 ($20 + $1 fee)
+ // },
+ // {
+ // id: "pi_3QJ8k7FT9vXn4A6r0987654321",
+ // paymentID: "pi_3QJ8k7FT9vXn4A6r0987654321",
+ // timeCreated: new Date(Date.now() - 86400000 * 15).toISOString(), // 15 days ago
+ // amount: 2100000000, // $21.00
+ // },
+ // {
+ // id: "pi_3QI5m1FT9vXn4A6r5678901234",
+ // paymentID: "pi_3QI5m1FT9vXn4A6r5678901234",
+ // timeCreated: new Date(Date.now() - 86400000 * 32).toISOString(), // 32 days ago
+ // amount: 2100000000, // $21.00
+ // },
+ // {
+ // id: "pi_3QH2n9FT9vXn4A6r3456789012",
+ // paymentID: "pi_3QH2n9FT9vXn4A6r3456789012",
+ // timeCreated: new Date(Date.now() - 86400000 * 47).toISOString(), // 47 days ago
+ // amount: 2100000000, // $21.00
+ // },
+ // {
+ // id: "pi_3QG7p4FT9vXn4A6r7890123456",
+ // paymentID: "pi_3QG7p4FT9vXn4A6r7890123456",
+ // timeCreated: new Date(Date.now() - 86400000 * 63).toISOString(), // 63 days ago
+ // amount: 2100000000, // $21.00
+ // },
+ // ]
+
+ return (
+ payments() &&
+ payments()!.length > 0 && (
+ <section class={styles.root}>
+ <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>
+ <th>Receipt</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>
+ <td data-slot="payment-receipt">
+ <button
+ onClick={async () => {
+ const receiptUrl = await downloadReceiptAction(params.id, payment.paymentID!)
+ if (receiptUrl) {
+ window.open(receiptUrl, "_blank")
+ }
+ }}
+ data-slot="receipt-button"
+ style="cursor: pointer;"
+ >
+ view
+ </button>
+ </td>
+ </tr>
+ )
+ }}
+ </For>
+ </tbody>
+ </table>
+ </div>
+ </section>
+ )
+ )
+}
diff --git a/packages/console/app/src/component/workspace/usage-section.module.css b/packages/console/app/src/component/workspace/usage-section.module.css
new file mode 100644
index 000000000..1a772ba87
--- /dev/null
+++ b/packages/console/app/src/component/workspace/usage-section.module.css
@@ -0,0 +1,88 @@
+.root {
+ [data-component="empty-state"] {
+ padding: var(--space-20) var(--space-6);
+ text-align: center;
+ border: 1px dashed var(--color-border);
+ border-radius: var(--border-radius-sm);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-2);
+
+ p {
+ line-height: 1.5;
+ font-size: var(--font-size-sm);
+ color: var(--color-text-muted);
+ }
+ }
+
+ [data-slot="usage-table"] {
+ overflow-x: auto;
+ }
+
+ [data-slot="usage-table-element"] {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--font-size-sm);
+
+ thead {
+ border-bottom: 1px solid var(--color-border);
+ }
+
+ th {
+ padding: var(--space-3) var(--space-4);
+ text-align: left;
+ font-weight: normal;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ }
+
+ td {
+ padding: var(--space-3) var(--space-4);
+ border-bottom: 1px solid var(--color-border-muted);
+ color: var(--color-text-muted);
+ font-family: var(--font-mono);
+
+ &[data-slot="usage-date"] {
+ color: var(--color-text);
+ }
+
+ &[data-slot="usage-model"] {
+ font-family: var(--font-sans);
+ font-weight: 400;
+ color: var(--color-text-secondary);
+ max-width: 200px;
+ word-break: break-word;
+ }
+
+ &[data-slot="usage-cost"] {
+ color: var(--color-text);
+ }
+ }
+
+ tbody tr {
+ &:last-child td {
+ border-bottom: none;
+ }
+ }
+
+ @media (max-width: 40rem) {
+ th,
+ td {
+ padding: var(--space-2) var(--space-3);
+ font-size: var(--font-size-xs);
+ }
+
+ th {
+ &:nth-child(2) /* Model */ {
+ display: none;
+ }
+ }
+
+ td {
+ &:nth-child(2) /* Model */ {
+ display: none;
+ }
+ }
+ }
+ }
+}
diff --git a/packages/console/app/src/component/workspace/usage-section.tsx b/packages/console/app/src/component/workspace/usage-section.tsx
new file mode 100644
index 000000000..e68670c6d
--- /dev/null
+++ b/packages/console/app/src/component/workspace/usage-section.tsx
@@ -0,0 +1,128 @@
+import { Billing } from "@opencode/console-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"
+import styles from "./usage-section.module.css"
+
+const getUsageInfo = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ return await Billing.usages()
+ }, workspaceID)
+}, "usage.list")
+
+export function UsageSection() {
+ const params = useParams()
+ // ORIGINAL CODE - COMMENTED OUT FOR TESTING
+ const usage = createAsync(() => getUsageInfo(params.id))
+
+ // DUMMY DATA FOR TESTING
+ // const usage = () => [
+ // {
+ // timeCreated: new Date(Date.now() - 86400000 * 0).toISOString(), // Today
+ // model: "claude-3-5-sonnet-20241022",
+ // inputTokens: 1247,
+ // outputTokens: 423,
+ // cost: 125400000, // $1.254
+ // },
+ // {
+ // timeCreated: new Date(Date.now() - 86400000 * 0.5).toISOString(), // 12 hours ago
+ // model: "claude-3-haiku-20240307",
+ // inputTokens: 892,
+ // outputTokens: 156,
+ // cost: 23500000, // $0.235
+ // },
+ // {
+ // timeCreated: new Date(Date.now() - 86400000 * 1).toISOString(), // Yesterday
+ // model: "claude-3-5-sonnet-20241022",
+ // inputTokens: 2134,
+ // outputTokens: 687,
+ // cost: 234700000, // $2.347
+ // },
+ // {
+ // timeCreated: new Date(Date.now() - 86400000 * 1.3).toISOString(), // 1.3 days ago
+ // model: "gpt-4o-mini",
+ // inputTokens: 567,
+ // outputTokens: 234,
+ // cost: 8900000, // $0.089
+ // },
+ // {
+ // timeCreated: new Date(Date.now() - 86400000 * 2).toISOString(), // 2 days ago
+ // model: "claude-3-opus-20240229",
+ // inputTokens: 1893,
+ // outputTokens: 945,
+ // cost: 445600000, // $4.456
+ // },
+ // {
+ // timeCreated: new Date(Date.now() - 86400000 * 2.7).toISOString(), // 2.7 days ago
+ // model: "gpt-4o",
+ // inputTokens: 1456,
+ // outputTokens: 532,
+ // cost: 156800000, // $1.568
+ // },
+ // {
+ // timeCreated: new Date(Date.now() - 86400000 * 3).toISOString(), // 3 days ago
+ // model: "claude-3-haiku-20240307",
+ // inputTokens: 634,
+ // outputTokens: 89,
+ // cost: 12300000, // $0.123
+ // },
+ // {
+ // timeCreated: new Date(Date.now() - 86400000 * 4).toISOString(), // 4 days ago
+ // model: "claude-3-5-sonnet-20241022",
+ // inputTokens: 3245,
+ // outputTokens: 1123,
+ // cost: 387200000, // $3.872
+ // },
+ // ]
+
+ return (
+ <section class={styles.root}>
+ <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>
+ )
+}