diff --git a/cloud/app/src/routes/[workspaceID].tsx b/cloud/app/src/routes/[workspaceID].tsx
index 706a6432..98d03753 100644
--- a/cloud/app/src/routes/[workspaceID].tsx
+++ b/cloud/app/src/routes/[workspaceID].tsx
@@ -1,15 +1,187 @@
-import { createAsync, query } from "@solidjs/router"
+import { Billing } from "@opencode/cloud-core/billing.js"
+import { Key } from "@opencode/cloud-core/key.js"
+import { action, createAsync, revalidate, query, useAction, useSubmission } from "@solidjs/router"
+import { createSignal, For, Show } from "solid-js"
import { getActor, withActor } from "~/context/auth"
-const getPosts = query(async () => {
- "use server"
- return withActor(() => {
- return "ok"
- })
-}, "posts")
+/////////////////////////////////////
+// Keys related queries and actions
+/////////////////////////////////////
+const listKeys = query(async () => {
+ "use server"
+ return withActor(() => Key.list())
+}, "keys")
+
+const createKey = action(async (name: string) => {
+ "use server"
+ return withActor(() => Key.create({ name }))
+}, "createKey")
+
+const removeKey = action(async (id: string) => {
+ "use server"
+ return withActor(() => Key.remove({ id }))
+}, "removeKey")
+
+/////////////////////////////////////
+// Billing related queries and actions
+/////////////////////////////////////
+
+const getBillingInfo = query(async () => {
+ "use server"
+ return withActor(async () => {
+ const billing = await Billing.get()
+ const payments = await Billing.payments()
+ const usage = await Billing.usages()
+ return { billing, payments, usage }
+ })
+}, "billingInfo")
+
+const createCheckoutUrl = action(async (successUrl: string, cancelUrl: string) => {
+ "use server"
+ return withActor(() => Billing.generateCheckoutUrl({ successUrl, cancelUrl }))
+}, "checkoutUrl")
+
+const createPortalUrl = action(async (returnUrl: string) => {
+ "use server"
+ return withActor(() => Billing.generatePortalUrl({ returnUrl }))
+}, "portalUrl")
+
+//export const route = {
+// preload: () => listKeys(),
+//}
export default function () {
- const actor = createAsync(async () => getActor())
- return
{JSON.stringify(actor())}
+ const actor = createAsync(() => getActor())
+ const keys = createAsync(() => listKeys())
+ const createKeyAction = useAction(createKey)
+ const removeKeyAction = useAction(removeKey)
+ const createKeySubmission = useSubmission(createKey)
+ const [showCreateForm, setShowCreateForm] = createSignal(false)
+ const [keyName, setKeyName] = createSignal("")
+
+ const formatDate = (date: Date) => {
+ return date.toLocaleDateString()
+ }
+
+ const formatKey = (key: string) => {
+ if (key.length <= 11) return key
+ return `${key.slice(0, 7)}...${key.slice(-4)}`
+ }
+
+ const copyToClipboard = async (text: string) => {
+ try {
+ await navigator.clipboard.writeText(text)
+ } catch (error) {
+ console.error("Failed to copy to clipboard:", error)
+ }
+ }
+
+ const handleCreateKey = async () => {
+ if (!keyName().trim()) return
+
+ try {
+ await createKeyAction(keyName().trim())
+ revalidate("keys")
+ setKeyName("")
+ setShowCreateForm(false)
+ } catch (error) {
+ console.error("Failed to create API key:", error)
+ }
+ }
+
+ const handleDeleteKey = async (keyId: string) => {
+ if (!confirm("Are you sure you want to delete this API key? This action cannot be undone.")) {
+ return
+ }
+
+ try {
+ await removeKeyAction(keyId)
+ revalidate("keys")
+ } catch (error) {
+ console.error("Failed to delete API key:", error)
+ }
+ }
+
+ return (
+
+
Actor
+
{JSON.stringify(actor())}
+
API Keys
+
+ setKeyName(e.currentTarget.value)}
+ onKeyPress={(e) => e.key === "Enter" && handleCreateKey()}
+ />
+
+
+
+
+
+ }
+ >
+
+
+
+
+ Create an API key to access opencode gateway
+
+ }
+ >
+ {(key) => (
+
+
+
{key.name}
+
{formatKey(key.key)}
+
+ Created: {formatDate(key.timeCreated)}
+ {key.timeUsed && ` • Last used: ${formatDate(key.timeUsed)}`}
+
+
+
+
+
+
+
+ )}
+
+
+
+ )
}
diff --git a/cloud/app/src/style/token/color.css b/cloud/app/src/style/token/color.css
index 35846acd..31d11e2d 100644
--- a/cloud/app/src/style/token/color.css
+++ b/cloud/app/src/style/token/color.css
@@ -47,7 +47,7 @@
@media (prefers-color-scheme: dark) {
:root {
/* OpenCode dark theme colors */
- --color-bg: #0c0c0e;
+ /*--color-bg: #0c0c0e;*/
--color-bg-surface: #161618;
--color-bg-elevated: #1c1c1f;
@@ -87,4 +87,4 @@
--color-surface-hover: var(--color-bg-elevated);
--color-border: var(--color-border);
}
-}
+}
\ No newline at end of file
diff --git a/cloud/core/src/billing.ts b/cloud/core/src/billing.ts
index 1a7bb294..94ba23b8 100644
--- a/cloud/core/src/billing.ts
+++ b/cloud/core/src/billing.ts
@@ -1,12 +1,13 @@
import { Resource } from "sst"
import { Stripe } from "stripe"
import { Database, eq, sql } from "./drizzle"
-import { BillingTable, UsageTable } from "./schema/billing.sql"
+import { BillingTable, PaymentTable, UsageTable } from "./schema/billing.sql"
import { Actor } from "./actor"
import { fn } from "./util/fn"
import { z } from "zod"
import { Identifier } from "./identifier"
import { centsToMicroCents } from "./util/price"
+import { User } from "./user"
export namespace Billing {
export const stripe = () =>
@@ -29,6 +30,28 @@ export namespace Billing {
)
}
+ export const payments = async () => {
+ return await Database.use((tx) =>
+ tx
+ .select()
+ .from(PaymentTable)
+ .where(eq(PaymentTable.workspaceID, Actor.workspace()))
+ .orderBy(sql`${PaymentTable.timeCreated} DESC`)
+ .limit(100),
+ )
+ }
+
+ export const usages = async () => {
+ return await Database.use((tx) =>
+ tx
+ .select()
+ .from(UsageTable)
+ .where(eq(UsageTable.workspaceID, Actor.workspace()))
+ .orderBy(sql`${UsageTable.timeCreated} DESC`)
+ .limit(100),
+ )
+ }
+
export const consume = fn(
z.object({
requestID: z.string().optional(),
@@ -68,4 +91,72 @@ export namespace Billing {
})
},
)
+
+ export const generateCheckoutUrl = fn(
+ z.object({
+ successUrl: z.string(),
+ cancelUrl: z.string(),
+ }),
+ async (input) => {
+ const account = Actor.assert("user")
+ const { successUrl, cancelUrl } = input
+
+ const user = await User.fromID(account.properties.userID)
+ const customer = await Billing.get()
+ const session = await Billing.stripe().checkout.sessions.create({
+ mode: "payment",
+ line_items: [
+ {
+ price_data: {
+ currency: "usd",
+ product_data: {
+ name: "opencode credits",
+ },
+ unit_amount: 2000, // $20 minimum
+ },
+ quantity: 1,
+ },
+ ],
+ payment_intent_data: {
+ setup_future_usage: "on_session",
+ },
+ ...(customer.customerID
+ ? { customer: customer.customerID }
+ : {
+ customer_email: user.email,
+ customer_creation: "always",
+ }),
+ metadata: {
+ workspaceID: Actor.workspace(),
+ },
+ currency: "usd",
+ payment_method_types: ["card"],
+ success_url: successUrl,
+ cancel_url: cancelUrl,
+ })
+
+ return session.url
+ },
+ )
+
+ export const generatePortalUrl = fn(
+ z.object({
+ returnUrl: z.string(),
+ }),
+ async (input) => {
+ const { returnUrl } = input
+
+ const customer = await Billing.get()
+ if (!customer?.customerID) {
+ throw new Error("No stripe customer ID")
+ }
+
+ const session = await Billing.stripe().billingPortal.sessions.create({
+ customer: customer.customerID,
+ return_url: returnUrl,
+ })
+
+ return session.url
+ },
+ )
}
diff --git a/cloud/core/src/key.ts b/cloud/core/src/key.ts
new file mode 100644
index 00000000..cf4f6e41
--- /dev/null
+++ b/cloud/core/src/key.ts
@@ -0,0 +1,79 @@
+import { z } from "zod"
+import { fn } from "./util/fn"
+import { Actor } from "./actor"
+import { and, Database, eq, sql } from "./drizzle"
+import { Identifier } from "./identifier"
+import { KeyTable } from "./schema/key.sql"
+
+export namespace Key {
+ export const list = async () => {
+ const user = Actor.assert("user")
+ const keys = await Database.use((tx) =>
+ tx
+ .select({
+ id: KeyTable.id,
+ name: KeyTable.name,
+ key: KeyTable.key,
+ userID: KeyTable.userID,
+ timeCreated: KeyTable.timeCreated,
+ timeUsed: KeyTable.timeUsed,
+ })
+ .from(KeyTable)
+ .where(eq(KeyTable.workspaceID, user.properties.workspaceID))
+ .orderBy(sql`${KeyTable.timeCreated} DESC`),
+ )
+ return keys
+ }
+
+ export const create = fn(z.object({ name: z.string().min(1).max(255) }), async (input) => {
+ const user = Actor.assert("user")
+ const { name } = input
+
+ // Generate secret key: sk- + 64 random characters (upper, lower, numbers)
+ const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
+ let randomPart = ""
+ for (let i = 0; i < 64; i++) {
+ randomPart += chars.charAt(Math.floor(Math.random() * chars.length))
+ }
+ const secretKey = `sk-${randomPart}`
+
+ const keyRecord = await Database.use((tx) =>
+ tx
+ .insert(KeyTable)
+ .values({
+ id: Identifier.create("key"),
+ workspaceID: user.properties.workspaceID,
+ userID: user.properties.userID,
+ name,
+ key: secretKey,
+ timeUsed: null,
+ })
+ .returning(),
+ )
+
+ return {
+ key: secretKey,
+ id: keyRecord[0].id,
+ name: keyRecord[0].name,
+ created: keyRecord[0].timeCreated,
+ }
+ })
+
+ export const remove = fn(z.object({ id: z.string() }), async (input) => {
+ const user = Actor.assert("user")
+ const { id } = input
+
+ const result = await Database.use((tx) =>
+ tx
+ .delete(KeyTable)
+ .where(and(eq(KeyTable.id, id), eq(KeyTable.workspaceID, user.properties.workspaceID)))
+ .returning({ id: KeyTable.id }),
+ )
+
+ if (result.length === 0) {
+ throw new Error("Key not found")
+ }
+
+ return { id: result[0].id }
+ })
+}
diff --git a/cloud/core/src/user.ts b/cloud/core/src/user.ts
new file mode 100644
index 00000000..7914926f
--- /dev/null
+++ b/cloud/core/src/user.ts
@@ -0,0 +1,18 @@
+import { z } from "zod"
+import { eq } from "drizzle-orm"
+import { fn } from "./util/fn"
+import { Database } from "./drizzle"
+import { UserTable } from "./schema/user.sql"
+
+export namespace User {
+ export const fromID = fn(z.string(), async (id) =>
+ Database.transaction(async (tx) => {
+ return tx
+ .select()
+ .from(UserTable)
+ .where(eq(UserTable.id, id))
+ .execute()
+ .then((rows) => rows[0])
+ }),
+ )
+}