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