Frank 5 месяцев назад
Родитель
Сommit
c6ef92634d

+ 179 - 7
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 () => {
+/////////////////////////////////////
+// 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(() => {
-    return "ok"
+  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 }
   })
-}, "posts")
+}, "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 <div>{JSON.stringify(actor())}</div>
+  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 (
+    <div>
+      <h1>Actor</h1>
+      <div>{JSON.stringify(actor())}</div>
+      <h1>API Keys</h1>
+      <Show
+        when={!showCreateForm()}
+        fallback={
+          <div data-slot="create-form">
+            <input
+              data-component="input"
+              type="text"
+              placeholder="Enter key name"
+              value={keyName()}
+              onInput={(e) => setKeyName(e.currentTarget.value)}
+              onKeyPress={(e) => e.key === "Enter" && handleCreateKey()}
+            />
+            <div data-slot="form-actions">
+              <button
+                color="primary"
+                disabled={createKeySubmission.pending || !keyName().trim()}
+                onClick={handleCreateKey}
+              >
+                {createKeySubmission.pending ? "Creating..." : "Create"}
+              </button>
+              <button
+                color="ghost"
+                onClick={() => {
+                  setShowCreateForm(false)
+                  setKeyName("")
+                }}
+              >
+                Cancel
+              </button>
+            </div>
+          </div>
+        }
+      >
+        <button
+          color="primary"
+          onClick={() => {
+            console.log("clicked")
+            setShowCreateForm(true)
+          }}
+        >
+          Create API Key
+        </button>
+      </Show>
+      <div data-slot="key-list">
+        <For
+          each={keys()}
+          fallback={
+            <div data-slot="empty-state">
+              <p>Create an API key to access opencode gateway</p>
+            </div>
+          }
+        >
+          {(key) => (
+            <div data-slot="key-item">
+              <div data-slot="key-info">
+                <div data-slot="key-name">{key.name}</div>
+                <div data-slot="key-value">{formatKey(key.key)}</div>
+                <div data-slot="key-meta">
+                  Created: {formatDate(key.timeCreated)}
+                  {key.timeUsed && ` • Last used: ${formatDate(key.timeUsed)}`}
+                </div>
+              </div>
+              <div data-slot="key-actions">
+                <button color="ghost" onClick={() => copyToClipboard(key.key)} title="Copy API key">
+                  Copy
+                </button>
+                <button color="ghost" onClick={() => handleDeleteKey(key.id)} title="Delete API key">
+                  Delete
+                </button>
+              </div>
+            </div>
+          )}
+        </For>
+      </div>
+    </div>
+  )
 }

+ 2 - 2
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);
   }
-}
+}

+ 92 - 1
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
+    },
+  )
 }

+ 79 - 0
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 }
+  })
+}

+ 18 - 0
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])
+    }),
+  )
+}