From 51e9979457bce0a6b528f1746fa99d09e48bb51b Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 9 Oct 2025 16:01:52 -0400 Subject: wip: zen nav bar --- packages/console/app/src/lib/beta.ts | 7 - packages/console/app/src/routes/workspace.tsx | 16 +- packages/console/app/src/routes/workspace/[id].css | 156 ++++------ packages/console/app/src/routes/workspace/[id].tsx | 89 ++---- .../[id]/billing/billing-section.module.css | 114 +++++++ .../workspace/[id]/billing/billing-section.tsx | 233 +++++++++++++++ .../src/routes/workspace/[id]/billing/index.css | 116 ++++++++ .../src/routes/workspace/[id]/billing/index.tsx | 24 ++ .../[id]/billing/monthly-limit-section.module.css | 102 +++++++ .../[id]/billing/monthly-limit-section.tsx | 139 +++++++++ .../[id]/billing/payment-section.module.css | 76 +++++ .../workspace/[id]/billing/payment-section.tsx | 112 +++++++ .../app/src/routes/workspace/[id]/common.tsx | 25 ++ .../app/src/routes/workspace/[id]/index.css | 116 ++++++++ .../app/src/routes/workspace/[id]/index.tsx | 39 +++ .../app/src/routes/workspace/[id]/keys/index.css | 116 ++++++++ .../app/src/routes/workspace/[id]/keys/index.tsx | 12 + .../workspace/[id]/keys/key-section.module.css | 172 +++++++++++ .../src/routes/workspace/[id]/keys/key-section.tsx | 185 ++++++++++++ .../src/routes/workspace/[id]/members/index.css | 116 ++++++++ .../src/routes/workspace/[id]/members/index.tsx | 12 + .../[id]/members/member-section.module.css | 179 +++++++++++ .../workspace/[id]/members/member-section.tsx | 328 +++++++++++++++++++++ .../routes/workspace/[id]/model-section.module.css | 122 ++++++++ .../src/routes/workspace/[id]/model-section.tsx | 93 ++++++ .../workspace/[id]/new-user-section.module.css | 163 ++++++++++ .../src/routes/workspace/[id]/new-user-section.tsx | 97 ++++++ .../workspace/[id]/provider-section.module.css | 107 +++++++ .../src/routes/workspace/[id]/provider-section.tsx | 163 ++++++++++ .../src/routes/workspace/[id]/settings/index.css | 116 ++++++++ .../src/routes/workspace/[id]/settings/index.tsx | 12 + .../[id]/settings/settings-section.module.css | 95 ++++++ .../workspace/[id]/settings/settings-section.tsx | 124 ++++++++ .../routes/workspace/[id]/usage-section.module.css | 88 ++++++ .../src/routes/workspace/[id]/usage-section.tsx | 128 ++++++++ .../routes/workspace/billing-section.module.css | 114 ------- .../app/src/routes/workspace/billing-section.tsx | 233 --------------- .../console/app/src/routes/workspace/common.tsx | 37 +-- .../console/app/src/routes/workspace/index.tsx | 0 .../src/routes/workspace/key-section.module.css | 172 ----------- .../app/src/routes/workspace/key-section.tsx | 185 ------------ .../src/routes/workspace/member-section.module.css | 179 ----------- .../app/src/routes/workspace/member-section.tsx | 328 --------------------- .../src/routes/workspace/model-section.module.css | 122 -------- .../app/src/routes/workspace/model-section.tsx | 93 ------ .../workspace/monthly-limit-section.module.css | 102 ------- .../src/routes/workspace/monthly-limit-section.tsx | 139 --------- .../routes/workspace/new-user-section.module.css | 163 ---------- .../app/src/routes/workspace/new-user-section.tsx | 97 ------ .../routes/workspace/payment-section.module.css | 76 ----- .../app/src/routes/workspace/payment-section.tsx | 114 ------- .../routes/workspace/provider-section.module.css | 107 ------- .../app/src/routes/workspace/provider-section.tsx | 163 ---------- .../routes/workspace/settings-section.module.css | 95 ------ .../app/src/routes/workspace/settings-section.tsx | 124 -------- .../src/routes/workspace/usage-section.module.css | 88 ------ .../app/src/routes/workspace/usage-section.tsx | 128 -------- 57 files changed, 3624 insertions(+), 3027 deletions(-) delete mode 100644 packages/console/app/src/lib/beta.ts create mode 100644 packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css create mode 100644 packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/billing/index.css create mode 100644 packages/console/app/src/routes/workspace/[id]/billing/index.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css create mode 100644 packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css create mode 100644 packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/common.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/index.css create mode 100644 packages/console/app/src/routes/workspace/[id]/index.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/keys/index.css create mode 100644 packages/console/app/src/routes/workspace/[id]/keys/index.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css create mode 100644 packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/members/index.css create mode 100644 packages/console/app/src/routes/workspace/[id]/members/index.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/members/member-section.module.css create mode 100644 packages/console/app/src/routes/workspace/[id]/members/member-section.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/model-section.module.css create mode 100644 packages/console/app/src/routes/workspace/[id]/model-section.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/new-user-section.module.css create mode 100644 packages/console/app/src/routes/workspace/[id]/new-user-section.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/provider-section.module.css create mode 100644 packages/console/app/src/routes/workspace/[id]/provider-section.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/settings/index.css create mode 100644 packages/console/app/src/routes/workspace/[id]/settings/index.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css create mode 100644 packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx create mode 100644 packages/console/app/src/routes/workspace/[id]/usage-section.module.css create mode 100644 packages/console/app/src/routes/workspace/[id]/usage-section.tsx delete mode 100644 packages/console/app/src/routes/workspace/billing-section.module.css delete mode 100644 packages/console/app/src/routes/workspace/billing-section.tsx delete mode 100644 packages/console/app/src/routes/workspace/index.tsx delete mode 100644 packages/console/app/src/routes/workspace/key-section.module.css delete mode 100644 packages/console/app/src/routes/workspace/key-section.tsx delete mode 100644 packages/console/app/src/routes/workspace/member-section.module.css delete mode 100644 packages/console/app/src/routes/workspace/member-section.tsx delete mode 100644 packages/console/app/src/routes/workspace/model-section.module.css delete mode 100644 packages/console/app/src/routes/workspace/model-section.tsx delete mode 100644 packages/console/app/src/routes/workspace/monthly-limit-section.module.css delete mode 100644 packages/console/app/src/routes/workspace/monthly-limit-section.tsx delete mode 100644 packages/console/app/src/routes/workspace/new-user-section.module.css delete mode 100644 packages/console/app/src/routes/workspace/new-user-section.tsx delete mode 100644 packages/console/app/src/routes/workspace/payment-section.module.css delete mode 100644 packages/console/app/src/routes/workspace/payment-section.tsx delete mode 100644 packages/console/app/src/routes/workspace/provider-section.module.css delete mode 100644 packages/console/app/src/routes/workspace/provider-section.tsx delete mode 100644 packages/console/app/src/routes/workspace/settings-section.module.css delete mode 100644 packages/console/app/src/routes/workspace/settings-section.tsx delete mode 100644 packages/console/app/src/routes/workspace/usage-section.module.css delete mode 100644 packages/console/app/src/routes/workspace/usage-section.tsx diff --git a/packages/console/app/src/lib/beta.ts b/packages/console/app/src/lib/beta.ts deleted file mode 100644 index d60a735ee..000000000 --- a/packages/console/app/src/lib/beta.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { query } from "@solidjs/router" -import { Resource } from "@opencode-ai/console-resource" - -export const beta = query(async (workspaceID?: string) => { - "use server" - return Resource.App.stage === "production" ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y" : true -}, "beta") diff --git a/packages/console/app/src/routes/workspace.tsx b/packages/console/app/src/routes/workspace.tsx index ac394f585..f87123d31 100644 --- a/packages/console/app/src/routes/workspace.tsx +++ b/packages/console/app/src/routes/workspace.tsx @@ -8,16 +8,16 @@ import { WorkspacePicker } from "./workspace-picker" import { withActor } from "~/context/auth.withActor" import { User } from "@opencode-ai/console-core/user.js" import { Actor } from "@opencode-ai/console-core/actor.js" -import { beta } from "~/lib/beta" +import { querySessionInfo } from "./workspace/common" -const getUserInfo = query(async (workspaceID: string) => { +const getUserEmail = query(async (workspaceID: string) => { "use server" return withActor(async () => { const actor = Actor.assert("user") const email = await User.getAccountEmail(actor.properties.userID) - return { email } + return email }, workspaceID) -}, "userInfo") +}, "userEmail") const logout = action(async () => { "use server" @@ -37,8 +37,8 @@ const logout = action(async () => { export default function WorkspaceLayout(props: RouteSectionProps) { const params = useParams() - const userInfo = createAsync(() => getUserInfo(params.id)) - const isBeta = createAsync(() => beta(params.id)) + const userEmail = createAsync(() => getUserEmail(params.id)) + const sessionInfo = createAsync(() => querySessionInfo(params.id)) return (
@@ -48,10 +48,10 @@ export default function WorkspaceLayout(props: RouteSectionProps) {
- + - {userInfo()?.email} + {userEmail()}
) } diff --git a/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.module.css new file mode 100644 index 000000000..0bb5709cb --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/billing/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/routes/workspace/[id]/billing/billing-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx new file mode 100644 index 000000000..295ad3396 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/billing/billing-section.tsx @@ -0,0 +1,233 @@ +import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router" +import { createMemo, Show } from "solid-js" +import { Billing } from "@opencode-ai/console-core/billing.js" +import { withActor } from "~/context/auth.withActor" +import { IconCreditCard } from "~/component/icon" +import styles from "./billing-section.module.css" +import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js" +import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js" + +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 setReload = action(async (form: FormData) => { + "use server" + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required" } + const reload = form.get("reload")?.toString() === "true" + return json( + await Database.use((tx) => + tx + .update(BillingTable) + .set({ + reload, + }) + .where(eq(BillingTable.workspaceID, workspaceID)), + ), + { revalidate: getBillingInfo.key }, + ) +}, "billing.setReload") + +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 setReloadSubmission = useSubmission(setReload) + 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) + }) + + const hasBalance = createMemo(() => { + return (balanceInfo()?.balance ?? 0) > 0 && balanceAmount() !== "0.00" + }) + + return ( +
+
+

Billing

+

+ Manage payments methods. Contact us if you have any questions. +

+
+
+ +
+

+ 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. +

+ + + + +
+
+
+
+
+ +
+
+ ----}> + •••• + {balanceInfo()?.paymentMethodLast4} + +
+
+
+ { + const baseUrl = window.location.href + const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl) + if (checkoutUrl) { + window.location.href = checkoutUrl + } + }} + > + {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"} + + } + > +
+ + + +
+
+ } + > + +
+ + + +
+ +
+
+
+ + + We'll load $20 (+$1.23 processing fee) and reload it when it reaches $5. +

+ } + > +

+ You have ${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()} remaining in + your account. You can continue using the API with your remaining balance. +

+
+
+ +

+ Your current balance is ${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()} + . We'll automatically reload $20 (+$1.23 processing fee) when it reaches $5. +

+
+
+
+
+ ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/billing/index.css b/packages/console/app/src/routes/workspace/[id]/billing/index.css new file mode 100644 index 000000000..5124c78cf --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/billing/index.css @@ -0,0 +1,116 @@ +[data-page="workspace-[id]"] { + max-width: 64rem; + padding: var(--space-10) var(--space-4); + margin: 0 auto; + width: 100%; + display: flex; + flex-direction: column; + gap: var(--space-10); + + @media (max-width: 30rem) { + padding-top: var(--space-4); + padding-bottom: var(--space-4); + + gap: var(--space-8); + } + + [data-slot="sections"] { + display: flex; + flex-direction: column; + gap: var(--space-16); + + @media (max-width: 30rem) { + gap: var(--space-8); + } + + section { + display: flex; + flex-direction: column; + gap: var(--space-8); + + @media (max-width: 30rem) { + gap: var(--space-6); + } + + /* Section titles */ + [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); + } + } + + p { + line-height: 1.5; + font-size: var(--font-size-md); + color: var(--color-text-muted); + + a { + color: var(--color-text-muted); + } + + @media (max-width: 30rem) { + font-size: var(--font-size-sm); + } + } + } + } + + section:not(:last-child) { + border-bottom: 1px solid var(--color-border); + padding-bottom: var(--space-16); + + @media (max-width: 30rem) { + padding-bottom: var(--space-8); + } + } + } + + /* Title section */ + [data-component="title-section"] { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding-bottom: var(--space-8); + border-bottom: 1px solid var(--color-border); + + @media (max-width: 30rem) { + padding-bottom: var(--space-6); + } + + h1 { + font-size: var(--font-size-2xl); + font-weight: 500; + line-height: 1.2; + letter-spacing: -0.03125rem; + margin: 0; + text-transform: uppercase; + + @media (max-width: 30rem) { + font-size: var(--font-size-xl); + } + } + + p { + line-height: 1.5; + font-size: var(--font-size-md); + color: var(--color-text-muted); + + a { + color: var(--color-text-muted); + } + } + } +} \ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/[id]/billing/index.tsx b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx new file mode 100644 index 000000000..913d4f92f --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/billing/index.tsx @@ -0,0 +1,24 @@ +import "./index.css" +import { MonthlyLimitSection } from "./monthly-limit-section" +import { BillingSection } from "./billing-section" +import { PaymentSection } from "./payment-section" +import { Show } from "solid-js" +import { createAsync, useParams } from "@solidjs/router" +import { querySessionInfo } from "../../common" + +export default function () { + const params = useParams() + const userInfo = createAsync(() => querySessionInfo(params.id)) + + return ( +
+
+ + + + + +
+
+ ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.module.css new file mode 100644 index 000000000..02de058e4 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/billing/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/routes/workspace/[id]/billing/monthly-limit-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/monthly-limit-section.tsx new file mode 100644 index 000000000..dbeda115c --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/billing/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-ai/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 ( +
+
+

Monthly Limit

+

Set a monthly spending limit for your account.

+
+
+
+
+ {balanceInfo()?.monthlyLimit ? $ : null} + {balanceInfo()?.monthlyLimit ?? "-"} +
+ +
+ (input = r)} + data-component="input" + name="limit" + type="number" + placeholder="50" + /> + + {(err) =>
{err()}
} +
+
+ +
+ + +
+ + } + > + +
+
+ No spending limit set.

}> +

+ 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) + })()} + . +

+
+
+
+ ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css new file mode 100644 index 000000000..2e1afe78b --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.module.css @@ -0,0 +1,76 @@ +.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); + + &[data-refunded="true"] { + text-decoration: line-through; + } + } + } + + 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/routes/workspace/[id]/billing/payment-section.tsx b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx new file mode 100644 index 000000000..d3520bea4 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/billing/payment-section.tsx @@ -0,0 +1,112 @@ +import { Billing } from "@opencode-ai/console-core/billing.js" +import { query, action, useParams, createAsync, useAction } from "@solidjs/router" +import { For, Show } 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() + 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 ( + 0}> +
+
+

Payments History

+

Recent payment transactions.

+
+
+ + + + + + + + + + + + {(payment) => { + const date = new Date(payment.timeCreated) + return ( + + + + + + + ) + }} + + +
DatePayment IDAmountReceipt
+ {formatDateForTable(date)} + {payment.id} + ${((payment.amount ?? 0) / 100000000).toFixed(2)} + + +
+
+
+
+ ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/common.tsx b/packages/console/app/src/routes/workspace/[id]/common.tsx new file mode 100644 index 000000000..f85fd8423 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/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/routes/workspace/[id]/index.css b/packages/console/app/src/routes/workspace/[id]/index.css new file mode 100644 index 000000000..5124c78cf --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/index.css @@ -0,0 +1,116 @@ +[data-page="workspace-[id]"] { + max-width: 64rem; + padding: var(--space-10) var(--space-4); + margin: 0 auto; + width: 100%; + display: flex; + flex-direction: column; + gap: var(--space-10); + + @media (max-width: 30rem) { + padding-top: var(--space-4); + padding-bottom: var(--space-4); + + gap: var(--space-8); + } + + [data-slot="sections"] { + display: flex; + flex-direction: column; + gap: var(--space-16); + + @media (max-width: 30rem) { + gap: var(--space-8); + } + + section { + display: flex; + flex-direction: column; + gap: var(--space-8); + + @media (max-width: 30rem) { + gap: var(--space-6); + } + + /* Section titles */ + [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); + } + } + + p { + line-height: 1.5; + font-size: var(--font-size-md); + color: var(--color-text-muted); + + a { + color: var(--color-text-muted); + } + + @media (max-width: 30rem) { + font-size: var(--font-size-sm); + } + } + } + } + + section:not(:last-child) { + border-bottom: 1px solid var(--color-border); + padding-bottom: var(--space-16); + + @media (max-width: 30rem) { + padding-bottom: var(--space-8); + } + } + } + + /* Title section */ + [data-component="title-section"] { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding-bottom: var(--space-8); + border-bottom: 1px solid var(--color-border); + + @media (max-width: 30rem) { + padding-bottom: var(--space-6); + } + + h1 { + font-size: var(--font-size-2xl); + font-weight: 500; + line-height: 1.2; + letter-spacing: -0.03125rem; + margin: 0; + text-transform: uppercase; + + @media (max-width: 30rem) { + font-size: var(--font-size-xl); + } + } + + p { + line-height: 1.5; + font-size: var(--font-size-md); + color: var(--color-text-muted); + + a { + color: var(--color-text-muted); + } + } + } +} \ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/[id]/index.tsx b/packages/console/app/src/routes/workspace/[id]/index.tsx new file mode 100644 index 000000000..1345bf40f --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/index.tsx @@ -0,0 +1,39 @@ +import "./index.css" +import { NewUserSection } from "./new-user-section" +import { UsageSection } from "./usage-section" +import { MemberSection } from "./members/member-section" +import { SettingsSection } from "./settings/settings-section" +import { ModelSection } from "./model-section" +import { ProviderSection } from "./provider-section" +import { Show } from "solid-js" +import { createAsync, useParams } from "@solidjs/router" +import { querySessionInfo } from "../common" + +export default function () { + const params = useParams() + const userInfo = createAsync(() => querySessionInfo(params.id)) + + return ( +
+
+

Zen

+

+ Curated list of models provided by opencode.{" "} + + Learn more + + . +

+
+ +
+ + + + + + +
+
+ ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/keys/index.css b/packages/console/app/src/routes/workspace/[id]/keys/index.css new file mode 100644 index 000000000..5124c78cf --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/keys/index.css @@ -0,0 +1,116 @@ +[data-page="workspace-[id]"] { + max-width: 64rem; + padding: var(--space-10) var(--space-4); + margin: 0 auto; + width: 100%; + display: flex; + flex-direction: column; + gap: var(--space-10); + + @media (max-width: 30rem) { + padding-top: var(--space-4); + padding-bottom: var(--space-4); + + gap: var(--space-8); + } + + [data-slot="sections"] { + display: flex; + flex-direction: column; + gap: var(--space-16); + + @media (max-width: 30rem) { + gap: var(--space-8); + } + + section { + display: flex; + flex-direction: column; + gap: var(--space-8); + + @media (max-width: 30rem) { + gap: var(--space-6); + } + + /* Section titles */ + [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); + } + } + + p { + line-height: 1.5; + font-size: var(--font-size-md); + color: var(--color-text-muted); + + a { + color: var(--color-text-muted); + } + + @media (max-width: 30rem) { + font-size: var(--font-size-sm); + } + } + } + } + + section:not(:last-child) { + border-bottom: 1px solid var(--color-border); + padding-bottom: var(--space-16); + + @media (max-width: 30rem) { + padding-bottom: var(--space-8); + } + } + } + + /* Title section */ + [data-component="title-section"] { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding-bottom: var(--space-8); + border-bottom: 1px solid var(--color-border); + + @media (max-width: 30rem) { + padding-bottom: var(--space-6); + } + + h1 { + font-size: var(--font-size-2xl); + font-weight: 500; + line-height: 1.2; + letter-spacing: -0.03125rem; + margin: 0; + text-transform: uppercase; + + @media (max-width: 30rem) { + font-size: var(--font-size-xl); + } + } + + p { + line-height: 1.5; + font-size: var(--font-size-md); + color: var(--color-text-muted); + + a { + color: var(--color-text-muted); + } + } + } +} \ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/[id]/keys/index.tsx b/packages/console/app/src/routes/workspace/[id]/keys/index.tsx new file mode 100644 index 000000000..0fd3cdbd3 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/keys/index.tsx @@ -0,0 +1,12 @@ +import "./index.css" +import { KeySection } from "./key-section" + +export default function () { + return ( +
+
+ +
+
+ ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css b/packages/console/app/src/routes/workspace/[id]/keys/key-section.module.css new file mode 100644 index 000000000..6a1d0c85f --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/keys/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/routes/workspace/[id]/keys/key-section.tsx b/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx new file mode 100644 index 000000000..22b82ae05 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/keys/key-section.tsx @@ -0,0 +1,185 @@ +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-ai/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" +import { Actor } from "@opencode-ai/console-core/actor.js" + +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({ + userID: Actor.assert("user").properties.userID, + 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()}> + Create API Key + + } + > +
+
+ (input = r)} data-component="input" name="name" type="text" placeholder="Enter key name" /> + + {(err) =>
{err()}
} +
+
+ +
+ + +
+
+
+ ) +} + +export function KeySection() { + const params = useParams() + const keys = createAsync(() => listKeys(params.id)) + + return ( +
+
+

API Keys

+

Manage your API keys for accessing opencode services.

+
+ +
+ +

Create an opencode Gateway API key

+
+ } + > + + + + + + + + + + + + + {(key) => { + const [copied, setCopied] = createSignal(false) + // const submission = useSubmission(removeKey, ([fd]) => fd.get("id")?.toString() === key.id) + return ( + + + + + + + + ) + }} + + +
NameKeyCreated ByLast Used
{key.name} + {key.keyDisplay}}> + + + {key.email} + {key.timeUsed ? formatDateForTable(key.timeUsed) : "-"} + +
+ + + +
+
+ + +
+ ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/members/index.css b/packages/console/app/src/routes/workspace/[id]/members/index.css new file mode 100644 index 000000000..5124c78cf --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/members/index.css @@ -0,0 +1,116 @@ +[data-page="workspace-[id]"] { + max-width: 64rem; + padding: var(--space-10) var(--space-4); + margin: 0 auto; + width: 100%; + display: flex; + flex-direction: column; + gap: var(--space-10); + + @media (max-width: 30rem) { + padding-top: var(--space-4); + padding-bottom: var(--space-4); + + gap: var(--space-8); + } + + [data-slot="sections"] { + display: flex; + flex-direction: column; + gap: var(--space-16); + + @media (max-width: 30rem) { + gap: var(--space-8); + } + + section { + display: flex; + flex-direction: column; + gap: var(--space-8); + + @media (max-width: 30rem) { + gap: var(--space-6); + } + + /* Section titles */ + [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); + } + } + + p { + line-height: 1.5; + font-size: var(--font-size-md); + color: var(--color-text-muted); + + a { + color: var(--color-text-muted); + } + + @media (max-width: 30rem) { + font-size: var(--font-size-sm); + } + } + } + } + + section:not(:last-child) { + border-bottom: 1px solid var(--color-border); + padding-bottom: var(--space-16); + + @media (max-width: 30rem) { + padding-bottom: var(--space-8); + } + } + } + + /* Title section */ + [data-component="title-section"] { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding-bottom: var(--space-8); + border-bottom: 1px solid var(--color-border); + + @media (max-width: 30rem) { + padding-bottom: var(--space-6); + } + + h1 { + font-size: var(--font-size-2xl); + font-weight: 500; + line-height: 1.2; + letter-spacing: -0.03125rem; + margin: 0; + text-transform: uppercase; + + @media (max-width: 30rem) { + font-size: var(--font-size-xl); + } + } + + p { + line-height: 1.5; + font-size: var(--font-size-md); + color: var(--color-text-muted); + + a { + color: var(--color-text-muted); + } + } + } +} \ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/[id]/members/index.tsx b/packages/console/app/src/routes/workspace/[id]/members/index.tsx new file mode 100644 index 000000000..5845e144c --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/members/index.tsx @@ -0,0 +1,12 @@ +import "./index.css" +import { MemberSection } from "./member-section" + +export default function () { + return ( +
+
+ +
+
+ ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css b/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css new file mode 100644 index 000000000..16b6ff8d2 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/members/member-section.module.css @@ -0,0 +1,179 @@ +.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="members-table"] { + overflow-x: auto; + } + + [data-slot="members-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="member-email"] { + color: var(--color-text); + font-family: var(--font-sans); + font-weight: 500; + } + + &[data-slot="member-role"] { + 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="member-date"] { + color: var(--color-text); + } + + &[data-slot="member-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; + } + } + } + } +} \ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx b/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx new file mode 100644 index 000000000..b13e8e5ed --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/members/member-section.tsx @@ -0,0 +1,328 @@ +import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router" +import { createEffect, createSignal, For, Show } from "solid-js" +import { withActor } from "~/context/auth.withActor" +import { createStore } from "solid-js/store" +import styles from "./member-section.module.css" +import { UserRole } from "@opencode-ai/console-core/schema/user.sql.js" +import { Actor } from "@opencode-ai/console-core/actor.js" +import { User } from "@opencode-ai/console-core/user.js" + +const listMembers = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + return { + members: await User.list(), + actorID: Actor.userID(), + actorRole: Actor.userRole(), + } + }, workspaceID) +}, "member.list") + +const inviteMember = action(async (form: FormData) => { + "use server" + const email = form.get("email")?.toString().trim() + if (!email) return { error: "Email is required" } + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required" } + const role = form.get("role")?.toString() as (typeof UserRole)[number] + if (!role) return { error: "Role is required" } + return json( + await withActor( + () => + User.invite({ email, role }) + .then((data) => ({ error: undefined, data })) + .catch((e) => ({ error: e.message as string })), + workspaceID, + ), + { revalidate: listMembers.key }, + ) +}, "member.create") + +const removeMember = 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( + () => + User.remove(id) + .then((data) => ({ error: undefined, data })) + .catch((e) => ({ error: e.message as string })), + workspaceID, + ), + { revalidate: listMembers.key }, + ) +}, "member.remove") + +const updateMember = 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" } + const role = form.get("role")?.toString() as (typeof UserRole)[number] + if (!role) return { error: "Role is required" } + const limit = form.get("limit")?.toString() + const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null + if (monthlyLimit !== null && monthlyLimit < 0) return { error: "Set a valid monthly limit" } + + return json( + await withActor( + () => + User.update({ id, role, monthlyLimit }) + .then((data) => ({ error: undefined, data })) + .catch((e) => ({ error: e.message as string })), + workspaceID, + ), + { revalidate: listMembers.key }, + ) +}, "member.update") + +export function MemberCreateForm() { + const params = useParams() + const submission = useSubmission(inviteMember) + 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()}> + Invite Member + + } + > +
+
+ (input = r)} data-component="input" name="email" type="text" placeholder="Enter email" /> +
+ + +
+ + {(err) =>
{err()}
} +
+
+ +
+ + +
+
+
+ ) +} + +function MemberRow(props: { member: any; workspaceID: string; actorID: string; actorRole: string }) { + const [editing, setEditing] = createSignal(false) + const submission = useSubmission(updateMember) + const isCurrentUser = () => props.actorID === props.member.id + const isAdmin = () => props.actorRole === "admin" + + createEffect(() => { + if (!submission.pending && submission.result && !submission.result.error) { + setEditing(false) + } + }) + + function getUsageDisplay() { + const currentUsage = (() => { + const dateLastUsed = props.member.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 ((props.member.monthlyUsage ?? 0) / 100000000).toFixed(2) + })() + + const limit = props.member.monthlyLimit ? `$${props.member.monthlyLimit}` : "no limit" + return `$${currentUsage} / ${limit}` + } + + return ( + + {props.member.accountEmail ?? props.member.email} + {props.member.role} + {getUsageDisplay()} + {props.member.timeSeen ? "" : "invited"} + + + + +
+ + + +
+
+
+ + + } + > + + +
+
{props.member.accountEmail ?? props.member.email}
+ + + + +
Role: {props.member.role}
+ + + } + > +
+ + +
+
+ +
+ +
+ + + {(err) =>
{err()}
} +
+ +
+ + +
+
+ + +
+ ) +} + +export function MemberSection() { + const params = useParams() + const data = createAsync(() => listMembers(params.id)) + + return ( +
+
+

Members

+
+ + + +
+ + + + + + + + + + + + + {(member) => ( + + )} + + +
EmailRoleUsage
+
+
+ ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/model-section.module.css b/packages/console/app/src/routes/workspace/[id]/model-section.module.css new file mode 100644 index 000000000..5a98c9b15 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/model-section.module.css @@ -0,0 +1,122 @@ +.root {} + +[data-slot="section-title"] { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +[data-slot="section-title"] h2 { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: var(--color-text); +} + +[data-slot="section-title"] p { + margin: 0; + color: var(--color-text-secondary); + font-size: 0.875rem; +} + +[data-slot="models-list"] { + display: flex; + flex-direction: column; +} + +[data-slot="models-table"] { + overflow-x: auto; +} + +[data-slot="models-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="model-name"] { + color: var(--color-text); + font-family: var(--font-mono); + font-weight: 500; + } + + &[data-slot="training-data"] { + text-align: center; + color: var(--color-text); + } + + &[data-slot="model-status"] { + text-align: left; + color: var(--color-text); + } + + &[data-slot="model-toggle"] { + text-align: left; + font-family: var(--font-sans); + } + } + + tbody tr { + &[data-enabled="false"] { + opacity: 0.6; + } + + &: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) + + /* Training Data */ + { + display: none; + } + } + + td { + &:nth-child(2) + + /* Training Data */ + { + display: none; + } + } + } +} + + +[data-component="empty-state"] { + display: flex; + align-items: center; + justify-content: center; + padding: 3rem; + color: var(--color-text-secondary); + font-size: 0.875rem; +} \ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/[id]/model-section.tsx b/packages/console/app/src/routes/workspace/[id]/model-section.tsx new file mode 100644 index 000000000..4128b4a2c --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/model-section.tsx @@ -0,0 +1,93 @@ +import { Model } from "@opencode-ai/console-core/model.js" +import { query, action, useParams, createAsync, json } from "@solidjs/router" +import { createMemo, For, Show } from "solid-js" +import { withActor } from "~/context/auth.withActor" +import { ZenModel } from "@opencode-ai/console-core/model.js" +import styles from "./model-section.module.css" + +const getModelsInfo = query(async (workspaceID: string) => { + "use server" + return withActor(async () => { + return { + all: Object.keys(ZenModel.list()) + .filter((model) => !["claude-3-5-haiku", "glm-4.6", "qwen3-max"].includes(model)) + .sort(([a], [b]) => a.localeCompare(b)), + disabled: await Model.listDisabled(), + } + }, workspaceID) +}, "model.info") + +const updateModel = action(async (form: FormData) => { + "use server" + const model = form.get("model")?.toString() + if (!model) return { error: "Model is required" } + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required" } + const enabled = form.get("enabled")?.toString() === "true" + return json( + withActor(async () => { + if (enabled) { + await Model.disable({ model }) + } else { + await Model.enable({ model }) + } + }, workspaceID), + { revalidate: getModelsInfo.key }, + ) +}, "model.toggle") + +export function ModelSection() { + const params = useParams() + const modelsInfo = createAsync(() => getModelsInfo(params.id)) + return ( +
+
+

Models

+

Manage models for your workspace.

+
+
+ +

Loading models...

+
+ } + > +
+ + + + + + + + + + + {(modelId) => { + const isEnabled = createMemo(() => !modelsInfo()!.disabled.includes(modelId)) + return ( + + + + + + ) + }} + + +
ModelStatusAction
{modelId}{isEnabled() ? "Enabled" : "Disabled"} +
+ + + + +
+
+
+ + +
+ ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/new-user-section.module.css b/packages/console/app/src/routes/workspace/[id]/new-user-section.module.css new file mode 100644 index 000000000..2edc7cc14 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/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/routes/workspace/[id]/new-user-section.tsx b/packages/console/app/src/routes/workspace/[id]/new-user-section.tsx new file mode 100644 index 000000000..b694801cc --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/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-ai/console-core/key.js" +import { Billing } from "@opencode-ai/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 ( + +
+
+
+

Tested & Verified Models

+

We've benchmarked and tested models specifically for coding agents to ensure the best performance.

+
+
+

Highest Quality

+

Access models configured for optimal performance - no downgrades or routing to cheaper providers.

+
+
+

No Lock-in

+

Use Zen with any coding agent, and continue using other providers with opencode whenever you want.

+
+
+ +
+ +
+
+ {defaultKey()} + +
+
+
+
+ +
+
    +
  1. Enable billing
  2. +
  3. + Run opencode auth login and select opencode +
  4. +
  5. Paste your API key
  6. +
  7. + Start opencode and run /models to select a model +
  8. +
+
+
+
+ ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/provider-section.module.css b/packages/console/app/src/routes/workspace/[id]/provider-section.module.css new file mode 100644 index 000000000..5f18862f5 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/provider-section.module.css @@ -0,0 +1,107 @@ +.root { + [data-slot="providers-table"] { + overflow-x: auto; + } + + [data-slot="providers-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="provider-name"] { + color: var(--color-text); + font-family: var(--font-mono); + font-weight: 500; + } + + &[data-slot="provider-status"] { + text-align: left; + color: var(--color-text); + } + + &[data-slot="provider-toggle"] { + text-align: left; + font-family: var(--font-sans); + + [data-slot="edit-form"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + + [data-slot="input-wrapper"] { + display: flex; + flex-direction: column; + gap: var(--space-1); + + input { + 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-error"] { + color: var(--color-danger); + font-size: var(--font-size-sm); + line-height: 1.4; + } + } + + [data-slot="form-actions"] { + display: flex; + gap: var(--space-2); + } + } + } + } + + tbody tr { + &[data-enabled="false"] { + opacity: 0.6; + } + + &: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); + } + } + } +} \ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/[id]/provider-section.tsx b/packages/console/app/src/routes/workspace/[id]/provider-section.tsx new file mode 100644 index 000000000..856b3a6a2 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/provider-section.tsx @@ -0,0 +1,163 @@ +import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router" +import { createEffect, For, Show } from "solid-js" +import { Provider } from "@opencode-ai/console-core/provider.js" +import { withActor } from "~/context/auth.withActor" +import { createStore } from "solid-js/store" +import styles from "./provider-section.module.css" + +const PROVIDERS = [ + { name: "OpenAI", key: "openai", prefix: "sk-" }, + { name: "Anthropic", key: "anthropic", prefix: "sk-ant-" }, +] as const + +type Provider = (typeof PROVIDERS)[number] + +const removeProvider = action(async (form: FormData) => { + "use server" + const provider = form.get("provider")?.toString() + if (!provider) return { error: "Provider is required" } + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required" } + return json(await withActor(() => Provider.remove({ provider }), workspaceID), { revalidate: listProviders.key }) +}, "provider.remove") + +const saveProvider = action(async (form: FormData) => { + "use server" + const provider = form.get("provider")?.toString() + const credentials = form.get("credentials")?.toString() + if (!provider) return { error: "Provider is required" } + if (!credentials) return { error: "API key is required" } + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required" } + return json( + await withActor( + () => + Provider.create({ provider, credentials }) + .then(() => ({ error: undefined })) + .catch((e) => ({ error: e.message as string })), + workspaceID, + ), + { revalidate: listProviders.key }, + ) +}, "provider.save") + +const listProviders = query(async (workspaceID: string) => { + "use server" + return withActor(() => Provider.list(), workspaceID) +}, "provider.list") + +function ProviderRow(props: { provider: Provider }) { + const params = useParams() + const providers = createAsync(() => listProviders(params.id)) + const saveSubmission = useSubmission(saveProvider, ([fd]) => fd.get("provider")?.toString() === props.provider.key) + const removeSubmission = useSubmission( + removeProvider, + ([fd]) => fd.get("provider")?.toString() === props.provider.key, + ) + const [store, setStore] = createStore({ editing: false }) + + let input: HTMLInputElement + + const isEnabled = () => providers()?.some((p) => p.provider === props.provider.key) + + createEffect(() => { + if (!saveSubmission.pending && saveSubmission.result && !saveSubmission.result.error) { + hide() + } + }) + + function show() { + while (true) { + saveSubmission.clear() + if (!saveSubmission.result) break + } + setStore("editing", true) + setTimeout(() => input?.focus(), 0) + } + + function hide() { + setStore("editing", false) + } + + return ( + + {props.provider.name} + {isEnabled() ? "Configured" : "Not Configured"} + + show()}> + Configure + + } + > +
+ + + +
+
+ } + > +
+
+ (input = r)} + name="credentials" + type="text" + placeholder={`Enter ${props.provider.name} API key (${props.provider.prefix}...)`} + autocomplete="off" + data-form-type="other" + data-lpignore="true" + /> + + {(err) =>
{err()}
} +
+
+ + +
+ + +
+
+ + + + ) +} + +export function ProviderSection() { + return ( +
+
+

Bring Your Own Key

+

Configure your own API keys from AI providers.

+
+
+ + + + + + + + + + {(provider) => } + +
ProviderStatusAction
+
+
+ ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/settings/index.css b/packages/console/app/src/routes/workspace/[id]/settings/index.css new file mode 100644 index 000000000..5124c78cf --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/settings/index.css @@ -0,0 +1,116 @@ +[data-page="workspace-[id]"] { + max-width: 64rem; + padding: var(--space-10) var(--space-4); + margin: 0 auto; + width: 100%; + display: flex; + flex-direction: column; + gap: var(--space-10); + + @media (max-width: 30rem) { + padding-top: var(--space-4); + padding-bottom: var(--space-4); + + gap: var(--space-8); + } + + [data-slot="sections"] { + display: flex; + flex-direction: column; + gap: var(--space-16); + + @media (max-width: 30rem) { + gap: var(--space-8); + } + + section { + display: flex; + flex-direction: column; + gap: var(--space-8); + + @media (max-width: 30rem) { + gap: var(--space-6); + } + + /* Section titles */ + [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); + } + } + + p { + line-height: 1.5; + font-size: var(--font-size-md); + color: var(--color-text-muted); + + a { + color: var(--color-text-muted); + } + + @media (max-width: 30rem) { + font-size: var(--font-size-sm); + } + } + } + } + + section:not(:last-child) { + border-bottom: 1px solid var(--color-border); + padding-bottom: var(--space-16); + + @media (max-width: 30rem) { + padding-bottom: var(--space-8); + } + } + } + + /* Title section */ + [data-component="title-section"] { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding-bottom: var(--space-8); + border-bottom: 1px solid var(--color-border); + + @media (max-width: 30rem) { + padding-bottom: var(--space-6); + } + + h1 { + font-size: var(--font-size-2xl); + font-weight: 500; + line-height: 1.2; + letter-spacing: -0.03125rem; + margin: 0; + text-transform: uppercase; + + @media (max-width: 30rem) { + font-size: var(--font-size-xl); + } + } + + p { + line-height: 1.5; + font-size: var(--font-size-md); + color: var(--color-text-muted); + + a { + color: var(--color-text-muted); + } + } + } +} \ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/[id]/settings/index.tsx b/packages/console/app/src/routes/workspace/[id]/settings/index.tsx new file mode 100644 index 000000000..972154aa3 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/settings/index.tsx @@ -0,0 +1,12 @@ +import "./index.css" +import { SettingsSection } from "./settings-section" + +export default function () { + return ( +
+
+ +
+
+ ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css new file mode 100644 index 000000000..e3a5ad508 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.module.css @@ -0,0 +1,95 @@ +.root { + [data-slot="section-content"] { + display: flex; + flex-direction: column; + gap: var(--space-4); + } + + [data-slot="setting"] { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-4); + border: 1px solid var(--color-border); + border-radius: var(--border-radius-sm); + + @media (max-width: 30rem) { + flex-direction: column; + gap: var(--space-3); + } + } + + [data-slot="setting-info"] { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--space-1); + + h3 { + font-size: var(--font-size-md); + font-weight: 500; + line-height: 1.2; + margin: 0; + color: var(--color-text); + } + + [data-slot="current-value"] { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + line-height: 1.4; + margin: 0; + } + } + + [data-slot="create-form"] { + display: flex; + flex-direction: column; + gap: var(--space-3); + min-width: 15rem; + width: fit-content; + + @media (max-width: 30rem) { + width: 100%; + min-width: auto; + } + + [data-slot="input-container"] { + display: flex; + flex-direction: column; + gap: var(--space-1); + } + + 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; + } + } +} diff --git a/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx new file mode 100644 index 000000000..0fc0158da --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/settings/settings-section.tsx @@ -0,0 +1,124 @@ +import { json, action, useParams, useSubmission, createAsync, query } from "@solidjs/router" +import { createEffect, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { withActor } from "~/context/auth.withActor" +import { Workspace } from "@opencode-ai/console-core/workspace.js" +import styles from "./settings-section.module.css" +import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js" +import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" + +const getWorkspaceInfo = query(async (workspaceID: string) => { + "use server" + return withActor( + () => + Database.use((tx) => + tx + .select({ + id: WorkspaceTable.id, + name: WorkspaceTable.name, + slug: WorkspaceTable.slug, + }) + .from(WorkspaceTable) + .where(eq(WorkspaceTable.id, workspaceID)) + .then((rows) => rows[0] || null), + ), + workspaceID, + ) +}, "workspace.get") + +const updateWorkspace = action(async (form: FormData) => { + "use server" + const name = form.get("name")?.toString().trim() + if (!name) return { error: "Workspace name is required." } + if (name.length > 255) return { error: "Name must be 255 characters or less." } + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required." } + return json( + await withActor( + () => + Workspace.update({ name }) + .then(() => ({ error: undefined })) + .catch((e) => ({ error: e.message as string })), + workspaceID, + ), + ) +}, "workspace.update") + +export function SettingsSection() { + const params = useParams() + const workspaceInfo = createAsync(() => getWorkspaceInfo(params.id)) + const submission = useSubmission(updateWorkspace) + const [store, setStore] = createStore({ show: false }) + + let input: HTMLInputElement + + createEffect(() => { + if (!submission.pending && submission.result && !submission.result.error) { + hide() + } + }) + + function show() { + while (true) { + submission.clear() + if (!submission.result) break + } + setStore("show", true) + input.focus() + } + + function hide() { + setStore("show", false) + } + + return ( +
+
+

Settings

+

Update your workspace name and preferences.

+
+
+
+
+

Workspace Name

+

{workspaceInfo()?.name}

+
+ +
+ (input = r)} + data-component="input" + name="name" + type="text" + placeholder="Workspace name" + value={workspaceInfo()?.name ?? "Default"} + /> + + {(err) =>
{err()}
} +
+
+ +
+ + +
+ + } + > + +
+
+
+
+ ) +} diff --git a/packages/console/app/src/routes/workspace/[id]/usage-section.module.css b/packages/console/app/src/routes/workspace/[id]/usage-section.module.css new file mode 100644 index 000000000..1a772ba87 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/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/routes/workspace/[id]/usage-section.tsx b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx new file mode 100644 index 000000000..9f65fe5f7 --- /dev/null +++ b/packages/console/app/src/routes/workspace/[id]/usage-section.tsx @@ -0,0 +1,128 @@ +import { Billing } from "@opencode-ai/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 ( +
+
+

Usage History

+

Recent API usage and costs.

+
+
+ 0} + fallback={ +
+

Make your first API call to get started.

+
+ } + > + + + + + + + + + + + + + {(usage) => { + const date = createMemo(() => new Date(usage.timeCreated)) + return ( + + + + + + + + ) + }} + + +
DateModelInputOutputCost
+ {formatDateForTable(date())} + {usage.model}{usage.inputTokens}{usage.outputTokens}${((usage.cost ?? 0) / 100000000).toFixed(4)}
+
+
+
+ ) +} diff --git a/packages/console/app/src/routes/workspace/billing-section.module.css b/packages/console/app/src/routes/workspace/billing-section.module.css deleted file mode 100644 index 0bb5709cb..000000000 --- a/packages/console/app/src/routes/workspace/billing-section.module.css +++ /dev/null @@ -1,114 +0,0 @@ -.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/routes/workspace/billing-section.tsx b/packages/console/app/src/routes/workspace/billing-section.tsx deleted file mode 100644 index 295ad3396..000000000 --- a/packages/console/app/src/routes/workspace/billing-section.tsx +++ /dev/null @@ -1,233 +0,0 @@ -import { json, query, action, useParams, useAction, createAsync, useSubmission } from "@solidjs/router" -import { createMemo, Show } from "solid-js" -import { Billing } from "@opencode-ai/console-core/billing.js" -import { withActor } from "~/context/auth.withActor" -import { IconCreditCard } from "~/component/icon" -import styles from "./billing-section.module.css" -import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js" -import { BillingTable } from "@opencode-ai/console-core/schema/billing.sql.js" - -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 setReload = action(async (form: FormData) => { - "use server" - const workspaceID = form.get("workspaceID")?.toString() - if (!workspaceID) return { error: "Workspace ID is required" } - const reload = form.get("reload")?.toString() === "true" - return json( - await Database.use((tx) => - tx - .update(BillingTable) - .set({ - reload, - }) - .where(eq(BillingTable.workspaceID, workspaceID)), - ), - { revalidate: getBillingInfo.key }, - ) -}, "billing.setReload") - -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 setReloadSubmission = useSubmission(setReload) - 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) - }) - - const hasBalance = createMemo(() => { - return (balanceInfo()?.balance ?? 0) > 0 && balanceAmount() !== "0.00" - }) - - return ( -
-
-

Billing

-

- Manage payments methods. Contact us if you have any questions. -

-
-
- -
-

- 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. -

-
- - -
-
-
-
-
-
- -
-
- ----}> - •••• - {balanceInfo()?.paymentMethodLast4} - -
-
-
- { - const baseUrl = window.location.href - const checkoutUrl = await createCheckoutUrlAction(params.id, baseUrl, baseUrl) - if (checkoutUrl) { - window.location.href = checkoutUrl - } - }} - > - {createCheckoutUrlSubmission.pending ? "Loading..." : "Enable Billing"} - - } - > -
- - - -
-
- } - > - -
- - - -
- -
-
-
- - - We'll load $20 (+$1.23 processing fee) and reload it when it reaches $5. -

- } - > -

- You have ${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()} remaining in - your account. You can continue using the API with your remaining balance. -

-
-
- -

- Your current balance is ${balanceAmount() === "-0.00" ? "0.00" : balanceAmount()} - . We'll automatically reload $20 (+$1.23 processing fee) when it reaches $5. -

-
-
-
-
- ) -} diff --git a/packages/console/app/src/routes/workspace/common.tsx b/packages/console/app/src/routes/workspace/common.tsx index f85fd8423..d1f1aba81 100644 --- a/packages/console/app/src/routes/workspace/common.tsx +++ b/packages/console/app/src/routes/workspace/common.tsx @@ -1,25 +1,14 @@ -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(",", ",") -} +import { Resource } from "@opencode-ai/console-resource" +import { Actor } from "@opencode-ai/console-core/actor.js" +import { query } from "@solidjs/router" +import { withActor } from "~/context/auth.withActor" -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) -} +export const querySessionInfo = query(async (workspaceID: string) => { + "use server" + return withActor(() => { + return { + isAdmin: Actor.userRole() === "admin", + isBeta: Resource.App.stage === "production" ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y" : true, + } + }, workspaceID) +}, "session.get") diff --git a/packages/console/app/src/routes/workspace/index.tsx b/packages/console/app/src/routes/workspace/index.tsx deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/console/app/src/routes/workspace/key-section.module.css b/packages/console/app/src/routes/workspace/key-section.module.css deleted file mode 100644 index 6a1d0c85f..000000000 --- a/packages/console/app/src/routes/workspace/key-section.module.css +++ /dev/null @@ -1,172 +0,0 @@ -.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/routes/workspace/key-section.tsx b/packages/console/app/src/routes/workspace/key-section.tsx deleted file mode 100644 index 3b7e399aa..000000000 --- a/packages/console/app/src/routes/workspace/key-section.tsx +++ /dev/null @@ -1,185 +0,0 @@ -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-ai/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" -import { Actor } from "@opencode-ai/console-core/actor.js" - -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({ - userID: Actor.assert("user").properties.userID, - 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()}> - Create API Key - - } - > -
-
- (input = r)} data-component="input" name="name" type="text" placeholder="Enter key name" /> - - {(err) =>
{err()}
} -
-
- -
- - -
-
-
- ) -} - -export function KeySection() { - const params = useParams() - const keys = createAsync(() => listKeys(params.id)) - - return ( -
-
-

API Keys

-

Manage your API keys for accessing opencode services.

-
- -
- -

Create an opencode Gateway API key

-
- } - > - - - - - - - - - - - - - {(key) => { - const [copied, setCopied] = createSignal(false) - // const submission = useSubmission(removeKey, ([fd]) => fd.get("id")?.toString() === key.id) - return ( - - - - - - - - ) - }} - - -
NameKeyCreated ByLast Used
{key.name} - {key.keyDisplay}}> - - - {key.email} - {key.timeUsed ? formatDateForTable(key.timeUsed) : "-"} - -
- - - -
-
- - -
- ) -} diff --git a/packages/console/app/src/routes/workspace/member-section.module.css b/packages/console/app/src/routes/workspace/member-section.module.css deleted file mode 100644 index 16b6ff8d2..000000000 --- a/packages/console/app/src/routes/workspace/member-section.module.css +++ /dev/null @@ -1,179 +0,0 @@ -.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="members-table"] { - overflow-x: auto; - } - - [data-slot="members-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="member-email"] { - color: var(--color-text); - font-family: var(--font-sans); - font-weight: 500; - } - - &[data-slot="member-role"] { - 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="member-date"] { - color: var(--color-text); - } - - &[data-slot="member-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; - } - } - } - } -} \ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/member-section.tsx b/packages/console/app/src/routes/workspace/member-section.tsx deleted file mode 100644 index b13e8e5ed..000000000 --- a/packages/console/app/src/routes/workspace/member-section.tsx +++ /dev/null @@ -1,328 +0,0 @@ -import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router" -import { createEffect, createSignal, For, Show } from "solid-js" -import { withActor } from "~/context/auth.withActor" -import { createStore } from "solid-js/store" -import styles from "./member-section.module.css" -import { UserRole } from "@opencode-ai/console-core/schema/user.sql.js" -import { Actor } from "@opencode-ai/console-core/actor.js" -import { User } from "@opencode-ai/console-core/user.js" - -const listMembers = query(async (workspaceID: string) => { - "use server" - return withActor(async () => { - return { - members: await User.list(), - actorID: Actor.userID(), - actorRole: Actor.userRole(), - } - }, workspaceID) -}, "member.list") - -const inviteMember = action(async (form: FormData) => { - "use server" - const email = form.get("email")?.toString().trim() - if (!email) return { error: "Email is required" } - const workspaceID = form.get("workspaceID")?.toString() - if (!workspaceID) return { error: "Workspace ID is required" } - const role = form.get("role")?.toString() as (typeof UserRole)[number] - if (!role) return { error: "Role is required" } - return json( - await withActor( - () => - User.invite({ email, role }) - .then((data) => ({ error: undefined, data })) - .catch((e) => ({ error: e.message as string })), - workspaceID, - ), - { revalidate: listMembers.key }, - ) -}, "member.create") - -const removeMember = 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( - () => - User.remove(id) - .then((data) => ({ error: undefined, data })) - .catch((e) => ({ error: e.message as string })), - workspaceID, - ), - { revalidate: listMembers.key }, - ) -}, "member.remove") - -const updateMember = 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" } - const role = form.get("role")?.toString() as (typeof UserRole)[number] - if (!role) return { error: "Role is required" } - const limit = form.get("limit")?.toString() - const monthlyLimit = limit && limit.trim() !== "" ? parseInt(limit) : null - if (monthlyLimit !== null && monthlyLimit < 0) return { error: "Set a valid monthly limit" } - - return json( - await withActor( - () => - User.update({ id, role, monthlyLimit }) - .then((data) => ({ error: undefined, data })) - .catch((e) => ({ error: e.message as string })), - workspaceID, - ), - { revalidate: listMembers.key }, - ) -}, "member.update") - -export function MemberCreateForm() { - const params = useParams() - const submission = useSubmission(inviteMember) - 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()}> - Invite Member - - } - > -
-
- (input = r)} data-component="input" name="email" type="text" placeholder="Enter email" /> -
- - -
- - {(err) =>
{err()}
} -
-
- -
- - -
-
-
- ) -} - -function MemberRow(props: { member: any; workspaceID: string; actorID: string; actorRole: string }) { - const [editing, setEditing] = createSignal(false) - const submission = useSubmission(updateMember) - const isCurrentUser = () => props.actorID === props.member.id - const isAdmin = () => props.actorRole === "admin" - - createEffect(() => { - if (!submission.pending && submission.result && !submission.result.error) { - setEditing(false) - } - }) - - function getUsageDisplay() { - const currentUsage = (() => { - const dateLastUsed = props.member.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 ((props.member.monthlyUsage ?? 0) / 100000000).toFixed(2) - })() - - const limit = props.member.monthlyLimit ? `$${props.member.monthlyLimit}` : "no limit" - return `$${currentUsage} / ${limit}` - } - - return ( - - {props.member.accountEmail ?? props.member.email} - {props.member.role} - {getUsageDisplay()} - {props.member.timeSeen ? "" : "invited"} - - - - -
- - - -
-
-
- - - } - > - - -
-
{props.member.accountEmail ?? props.member.email}
- - - - -
Role: {props.member.role}
- - - } - > -
- - -
-
- -
- -
- - - {(err) =>
{err()}
} -
- -
- - -
-
- - -
- ) -} - -export function MemberSection() { - const params = useParams() - const data = createAsync(() => listMembers(params.id)) - - return ( -
-
-

Members

-
- - - -
- - - - - - - - - - - - - {(member) => ( - - )} - - -
EmailRoleUsage
-
-
- ) -} diff --git a/packages/console/app/src/routes/workspace/model-section.module.css b/packages/console/app/src/routes/workspace/model-section.module.css deleted file mode 100644 index 5a98c9b15..000000000 --- a/packages/console/app/src/routes/workspace/model-section.module.css +++ /dev/null @@ -1,122 +0,0 @@ -.root {} - -[data-slot="section-title"] { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -[data-slot="section-title"] h2 { - margin: 0; - font-size: 1.25rem; - font-weight: 600; - color: var(--color-text); -} - -[data-slot="section-title"] p { - margin: 0; - color: var(--color-text-secondary); - font-size: 0.875rem; -} - -[data-slot="models-list"] { - display: flex; - flex-direction: column; -} - -[data-slot="models-table"] { - overflow-x: auto; -} - -[data-slot="models-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="model-name"] { - color: var(--color-text); - font-family: var(--font-mono); - font-weight: 500; - } - - &[data-slot="training-data"] { - text-align: center; - color: var(--color-text); - } - - &[data-slot="model-status"] { - text-align: left; - color: var(--color-text); - } - - &[data-slot="model-toggle"] { - text-align: left; - font-family: var(--font-sans); - } - } - - tbody tr { - &[data-enabled="false"] { - opacity: 0.6; - } - - &: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) - - /* Training Data */ - { - display: none; - } - } - - td { - &:nth-child(2) - - /* Training Data */ - { - display: none; - } - } - } -} - - -[data-component="empty-state"] { - display: flex; - align-items: center; - justify-content: center; - padding: 3rem; - color: var(--color-text-secondary); - font-size: 0.875rem; -} \ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/model-section.tsx b/packages/console/app/src/routes/workspace/model-section.tsx deleted file mode 100644 index 4128b4a2c..000000000 --- a/packages/console/app/src/routes/workspace/model-section.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { Model } from "@opencode-ai/console-core/model.js" -import { query, action, useParams, createAsync, json } from "@solidjs/router" -import { createMemo, For, Show } from "solid-js" -import { withActor } from "~/context/auth.withActor" -import { ZenModel } from "@opencode-ai/console-core/model.js" -import styles from "./model-section.module.css" - -const getModelsInfo = query(async (workspaceID: string) => { - "use server" - return withActor(async () => { - return { - all: Object.keys(ZenModel.list()) - .filter((model) => !["claude-3-5-haiku", "glm-4.6", "qwen3-max"].includes(model)) - .sort(([a], [b]) => a.localeCompare(b)), - disabled: await Model.listDisabled(), - } - }, workspaceID) -}, "model.info") - -const updateModel = action(async (form: FormData) => { - "use server" - const model = form.get("model")?.toString() - if (!model) return { error: "Model is required" } - const workspaceID = form.get("workspaceID")?.toString() - if (!workspaceID) return { error: "Workspace ID is required" } - const enabled = form.get("enabled")?.toString() === "true" - return json( - withActor(async () => { - if (enabled) { - await Model.disable({ model }) - } else { - await Model.enable({ model }) - } - }, workspaceID), - { revalidate: getModelsInfo.key }, - ) -}, "model.toggle") - -export function ModelSection() { - const params = useParams() - const modelsInfo = createAsync(() => getModelsInfo(params.id)) - return ( -
-
-

Models

-

Manage models for your workspace.

-
-
- -

Loading models...

-
- } - > -
- - - - - - - - - - - {(modelId) => { - const isEnabled = createMemo(() => !modelsInfo()!.disabled.includes(modelId)) - return ( - - - - - - ) - }} - - -
ModelStatusAction
{modelId}{isEnabled() ? "Enabled" : "Disabled"} -
- - - - -
-
-
- - -
- ) -} diff --git a/packages/console/app/src/routes/workspace/monthly-limit-section.module.css b/packages/console/app/src/routes/workspace/monthly-limit-section.module.css deleted file mode 100644 index 02de058e4..000000000 --- a/packages/console/app/src/routes/workspace/monthly-limit-section.module.css +++ /dev/null @@ -1,102 +0,0 @@ -.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/routes/workspace/monthly-limit-section.tsx b/packages/console/app/src/routes/workspace/monthly-limit-section.tsx deleted file mode 100644 index dbeda115c..000000000 --- a/packages/console/app/src/routes/workspace/monthly-limit-section.tsx +++ /dev/null @@ -1,139 +0,0 @@ -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-ai/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 ( -
-
-

Monthly Limit

-

Set a monthly spending limit for your account.

-
-
-
-
- {balanceInfo()?.monthlyLimit ? $ : null} - {balanceInfo()?.monthlyLimit ?? "-"} -
- -
- (input = r)} - data-component="input" - name="limit" - type="number" - placeholder="50" - /> - - {(err) =>
{err()}
} -
-
- -
- - -
- - } - > - -
-
- No spending limit set.

}> -

- 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) - })()} - . -

-
-
-
- ) -} diff --git a/packages/console/app/src/routes/workspace/new-user-section.module.css b/packages/console/app/src/routes/workspace/new-user-section.module.css deleted file mode 100644 index 2edc7cc14..000000000 --- a/packages/console/app/src/routes/workspace/new-user-section.module.css +++ /dev/null @@ -1,163 +0,0 @@ -.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/routes/workspace/new-user-section.tsx b/packages/console/app/src/routes/workspace/new-user-section.tsx deleted file mode 100644 index b694801cc..000000000 --- a/packages/console/app/src/routes/workspace/new-user-section.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { query, useParams, createAsync } from "@solidjs/router" -import { createMemo, createSignal, Show } from "solid-js" -import { IconCopy, IconCheck } from "~/component/icon" -import { Key } from "@opencode-ai/console-core/key.js" -import { Billing } from "@opencode-ai/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 ( - -
-
-
-

Tested & Verified Models

-

We've benchmarked and tested models specifically for coding agents to ensure the best performance.

-
-
-

Highest Quality

-

Access models configured for optimal performance - no downgrades or routing to cheaper providers.

-
-
-

No Lock-in

-

Use Zen with any coding agent, and continue using other providers with opencode whenever you want.

-
-
- -
- -
-
- {defaultKey()} - -
-
-
-
- -
-
    -
  1. Enable billing
  2. -
  3. - Run opencode auth login and select opencode -
  4. -
  5. Paste your API key
  6. -
  7. - Start opencode and run /models to select a model -
  8. -
-
-
-
- ) -} diff --git a/packages/console/app/src/routes/workspace/payment-section.module.css b/packages/console/app/src/routes/workspace/payment-section.module.css deleted file mode 100644 index 2e1afe78b..000000000 --- a/packages/console/app/src/routes/workspace/payment-section.module.css +++ /dev/null @@ -1,76 +0,0 @@ -.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); - - &[data-refunded="true"] { - text-decoration: line-through; - } - } - } - - 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/routes/workspace/payment-section.tsx b/packages/console/app/src/routes/workspace/payment-section.tsx deleted file mode 100644 index c35a50660..000000000 --- a/packages/console/app/src/routes/workspace/payment-section.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { Billing } from "@opencode-ai/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 && ( -
-
-

Payments History

-

Recent payment transactions.

-
-
- - - - - - - - - - - - {(payment) => { - const date = new Date(payment.timeCreated) - return ( - - - - - - - ) - }} - - -
DatePayment IDAmountReceipt
- {formatDateForTable(date)} - {payment.id} - ${((payment.amount ?? 0) / 100000000).toFixed(2)} - - -
-
-
- ) - ) -} diff --git a/packages/console/app/src/routes/workspace/provider-section.module.css b/packages/console/app/src/routes/workspace/provider-section.module.css deleted file mode 100644 index 5f18862f5..000000000 --- a/packages/console/app/src/routes/workspace/provider-section.module.css +++ /dev/null @@ -1,107 +0,0 @@ -.root { - [data-slot="providers-table"] { - overflow-x: auto; - } - - [data-slot="providers-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="provider-name"] { - color: var(--color-text); - font-family: var(--font-mono); - font-weight: 500; - } - - &[data-slot="provider-status"] { - text-align: left; - color: var(--color-text); - } - - &[data-slot="provider-toggle"] { - text-align: left; - font-family: var(--font-sans); - - [data-slot="edit-form"] { - display: flex; - flex-direction: column; - gap: var(--space-3); - - [data-slot="input-wrapper"] { - display: flex; - flex-direction: column; - gap: var(--space-1); - - input { - 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-error"] { - color: var(--color-danger); - font-size: var(--font-size-sm); - line-height: 1.4; - } - } - - [data-slot="form-actions"] { - display: flex; - gap: var(--space-2); - } - } - } - } - - tbody tr { - &[data-enabled="false"] { - opacity: 0.6; - } - - &: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); - } - } - } -} \ No newline at end of file diff --git a/packages/console/app/src/routes/workspace/provider-section.tsx b/packages/console/app/src/routes/workspace/provider-section.tsx deleted file mode 100644 index 856b3a6a2..000000000 --- a/packages/console/app/src/routes/workspace/provider-section.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { json, query, action, useParams, createAsync, useSubmission } from "@solidjs/router" -import { createEffect, For, Show } from "solid-js" -import { Provider } from "@opencode-ai/console-core/provider.js" -import { withActor } from "~/context/auth.withActor" -import { createStore } from "solid-js/store" -import styles from "./provider-section.module.css" - -const PROVIDERS = [ - { name: "OpenAI", key: "openai", prefix: "sk-" }, - { name: "Anthropic", key: "anthropic", prefix: "sk-ant-" }, -] as const - -type Provider = (typeof PROVIDERS)[number] - -const removeProvider = action(async (form: FormData) => { - "use server" - const provider = form.get("provider")?.toString() - if (!provider) return { error: "Provider is required" } - const workspaceID = form.get("workspaceID")?.toString() - if (!workspaceID) return { error: "Workspace ID is required" } - return json(await withActor(() => Provider.remove({ provider }), workspaceID), { revalidate: listProviders.key }) -}, "provider.remove") - -const saveProvider = action(async (form: FormData) => { - "use server" - const provider = form.get("provider")?.toString() - const credentials = form.get("credentials")?.toString() - if (!provider) return { error: "Provider is required" } - if (!credentials) return { error: "API key is required" } - const workspaceID = form.get("workspaceID")?.toString() - if (!workspaceID) return { error: "Workspace ID is required" } - return json( - await withActor( - () => - Provider.create({ provider, credentials }) - .then(() => ({ error: undefined })) - .catch((e) => ({ error: e.message as string })), - workspaceID, - ), - { revalidate: listProviders.key }, - ) -}, "provider.save") - -const listProviders = query(async (workspaceID: string) => { - "use server" - return withActor(() => Provider.list(), workspaceID) -}, "provider.list") - -function ProviderRow(props: { provider: Provider }) { - const params = useParams() - const providers = createAsync(() => listProviders(params.id)) - const saveSubmission = useSubmission(saveProvider, ([fd]) => fd.get("provider")?.toString() === props.provider.key) - const removeSubmission = useSubmission( - removeProvider, - ([fd]) => fd.get("provider")?.toString() === props.provider.key, - ) - const [store, setStore] = createStore({ editing: false }) - - let input: HTMLInputElement - - const isEnabled = () => providers()?.some((p) => p.provider === props.provider.key) - - createEffect(() => { - if (!saveSubmission.pending && saveSubmission.result && !saveSubmission.result.error) { - hide() - } - }) - - function show() { - while (true) { - saveSubmission.clear() - if (!saveSubmission.result) break - } - setStore("editing", true) - setTimeout(() => input?.focus(), 0) - } - - function hide() { - setStore("editing", false) - } - - return ( - - {props.provider.name} - {isEnabled() ? "Configured" : "Not Configured"} - - show()}> - Configure - - } - > -
- - - -
-
- } - > -
-
- (input = r)} - name="credentials" - type="text" - placeholder={`Enter ${props.provider.name} API key (${props.provider.prefix}...)`} - autocomplete="off" - data-form-type="other" - data-lpignore="true" - /> - - {(err) =>
{err()}
} -
-
- - -
- - -
-
- - - - ) -} - -export function ProviderSection() { - return ( -
-
-

Bring Your Own Key

-

Configure your own API keys from AI providers.

-
-
- - - - - - - - - - {(provider) => } - -
ProviderStatusAction
-
-
- ) -} diff --git a/packages/console/app/src/routes/workspace/settings-section.module.css b/packages/console/app/src/routes/workspace/settings-section.module.css deleted file mode 100644 index e3a5ad508..000000000 --- a/packages/console/app/src/routes/workspace/settings-section.module.css +++ /dev/null @@ -1,95 +0,0 @@ -.root { - [data-slot="section-content"] { - display: flex; - flex-direction: column; - gap: var(--space-4); - } - - [data-slot="setting"] { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--space-4); - padding: var(--space-4); - border: 1px solid var(--color-border); - border-radius: var(--border-radius-sm); - - @media (max-width: 30rem) { - flex-direction: column; - gap: var(--space-3); - } - } - - [data-slot="setting-info"] { - flex: 1; - display: flex; - flex-direction: column; - gap: var(--space-1); - - h3 { - font-size: var(--font-size-md); - font-weight: 500; - line-height: 1.2; - margin: 0; - color: var(--color-text); - } - - [data-slot="current-value"] { - font-size: var(--font-size-sm); - color: var(--color-text-muted); - line-height: 1.4; - margin: 0; - } - } - - [data-slot="create-form"] { - display: flex; - flex-direction: column; - gap: var(--space-3); - min-width: 15rem; - width: fit-content; - - @media (max-width: 30rem) { - width: 100%; - min-width: auto; - } - - [data-slot="input-container"] { - display: flex; - flex-direction: column; - gap: var(--space-1); - } - - 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; - } - } -} diff --git a/packages/console/app/src/routes/workspace/settings-section.tsx b/packages/console/app/src/routes/workspace/settings-section.tsx deleted file mode 100644 index 0fc0158da..000000000 --- a/packages/console/app/src/routes/workspace/settings-section.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import { json, action, useParams, useSubmission, createAsync, query } from "@solidjs/router" -import { createEffect, Show } from "solid-js" -import { createStore } from "solid-js/store" -import { withActor } from "~/context/auth.withActor" -import { Workspace } from "@opencode-ai/console-core/workspace.js" -import styles from "./settings-section.module.css" -import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js" -import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js" - -const getWorkspaceInfo = query(async (workspaceID: string) => { - "use server" - return withActor( - () => - Database.use((tx) => - tx - .select({ - id: WorkspaceTable.id, - name: WorkspaceTable.name, - slug: WorkspaceTable.slug, - }) - .from(WorkspaceTable) - .where(eq(WorkspaceTable.id, workspaceID)) - .then((rows) => rows[0] || null), - ), - workspaceID, - ) -}, "workspace.get") - -const updateWorkspace = action(async (form: FormData) => { - "use server" - const name = form.get("name")?.toString().trim() - if (!name) return { error: "Workspace name is required." } - if (name.length > 255) return { error: "Name must be 255 characters or less." } - const workspaceID = form.get("workspaceID")?.toString() - if (!workspaceID) return { error: "Workspace ID is required." } - return json( - await withActor( - () => - Workspace.update({ name }) - .then(() => ({ error: undefined })) - .catch((e) => ({ error: e.message as string })), - workspaceID, - ), - ) -}, "workspace.update") - -export function SettingsSection() { - const params = useParams() - const workspaceInfo = createAsync(() => getWorkspaceInfo(params.id)) - const submission = useSubmission(updateWorkspace) - const [store, setStore] = createStore({ show: false }) - - let input: HTMLInputElement - - createEffect(() => { - if (!submission.pending && submission.result && !submission.result.error) { - hide() - } - }) - - function show() { - while (true) { - submission.clear() - if (!submission.result) break - } - setStore("show", true) - input.focus() - } - - function hide() { - setStore("show", false) - } - - return ( -
-
-

Settings

-

Update your workspace name and preferences.

-
-
-
-
-

Workspace Name

-

{workspaceInfo()?.name}

-
- -
- (input = r)} - data-component="input" - name="name" - type="text" - placeholder="Workspace name" - value={workspaceInfo()?.name ?? "Default"} - /> - - {(err) =>
{err()}
} -
-
- -
- - -
- - } - > - -
-
-
-
- ) -} diff --git a/packages/console/app/src/routes/workspace/usage-section.module.css b/packages/console/app/src/routes/workspace/usage-section.module.css deleted file mode 100644 index 1a772ba87..000000000 --- a/packages/console/app/src/routes/workspace/usage-section.module.css +++ /dev/null @@ -1,88 +0,0 @@ -.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/routes/workspace/usage-section.tsx b/packages/console/app/src/routes/workspace/usage-section.tsx deleted file mode 100644 index 9f65fe5f7..000000000 --- a/packages/console/app/src/routes/workspace/usage-section.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import { Billing } from "@opencode-ai/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 ( -
-
-

Usage History

-

Recent API usage and costs.

-
-
- 0} - fallback={ -
-

Make your first API call to get started.

-
- } - > - - - - - - - - - - - - - {(usage) => { - const date = createMemo(() => new Date(usage.timeCreated)) - return ( - - - - - - - - ) - }} - - -
DateModelInputOutputCost
- {formatDateForTable(date())} - {usage.model}{usage.inputTokens}{usage.outputTokens}${((usage.cost ?? 0) / 100000000).toFixed(4)}
-
-
-
- ) -} -- cgit v1.2.3