summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-10-06 16:15:10 -0400
committerFrank <[email protected]>2025-10-06 17:13:19 -0400
commit9e8fd16e6e4154ae0bccff8342e4b0c7780d8db8 (patch)
treec9f936bfcaf7cf7f275334e869738c87989a2362
parent1b17d8070bcddeddaea3dea403f031a161539901 (diff)
downloadopencode-9e8fd16e6e4154ae0bccff8342e4b0c7780d8db8.tar.gz
opencode-9e8fd16e6e4154ae0bccff8342e4b0c7780d8db8.zip
wip: zen
-rw-r--r--packages/console/app/src/context/auth.ts1
-rw-r--r--packages/console/app/src/lib/beta.ts7
-rw-r--r--packages/console/app/src/routes/auth/index.ts22
-rw-r--r--packages/console/app/src/routes/workspace-picker.css184
-rw-r--r--packages/console/app/src/routes/workspace-picker.tsx144
-rw-r--r--packages/console/app/src/routes/workspace.tsx11
-rw-r--r--packages/console/app/src/routes/workspace/[id].tsx14
-rw-r--r--packages/console/core/src/account.ts17
-rw-r--r--packages/console/core/src/actor.ts9
-rw-r--r--packages/console/core/src/schema/workspace.sql.ts2
-rw-r--r--packages/console/core/src/user.ts2
-rw-r--r--packages/console/core/src/workspace.ts64
-rw-r--r--packages/console/function/src/auth.ts22
13 files changed, 437 insertions, 62 deletions
diff --git a/packages/console/app/src/context/auth.ts b/packages/console/app/src/context/auth.ts
index 14a876fda..14f275565 100644
--- a/packages/console/app/src/context/auth.ts
+++ b/packages/console/app/src/context/auth.ts
@@ -73,6 +73,7 @@ export const getActor = async (workspace?: string): Promise<Actor.Info> => {
properties: {
userID: user.id,
workspaceID: user.workspaceID,
+ accountID: user.accountID,
},
}
}
diff --git a/packages/console/app/src/lib/beta.ts b/packages/console/app/src/lib/beta.ts
new file mode 100644
index 000000000..910731c54
--- /dev/null
+++ b/packages/console/app/src/lib/beta.ts
@@ -0,0 +1,7 @@
+import { query } from "@solidjs/router"
+import { Resource } from "@opencode/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/auth/index.ts b/packages/console/app/src/routes/auth/index.ts
index 59d172386..f522e761d 100644
--- a/packages/console/app/src/routes/auth/index.ts
+++ b/packages/console/app/src/routes/auth/index.ts
@@ -1,11 +1,29 @@
-import { Account } from "@opencode-ai/console-core/account.js"
+import { Actor } from "@opencode-ai/console-core/actor.js"
+import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
+import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
+import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { redirect } from "@solidjs/router"
import type { APIEvent } from "@solidjs/start/server"
import { withActor } from "~/context/auth.withActor"
export async function GET(input: APIEvent) {
try {
- const workspaces = await withActor(async () => Account.workspaces())
+ const workspaces = await withActor(async () => {
+ const actor = Actor.assert("account")
+ return Database.transaction(async (tx) =>
+ tx
+ .select({ id: WorkspaceTable.id })
+ .from(UserTable)
+ .innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id))
+ .where(
+ and(
+ eq(UserTable.accountID, actor.properties.accountID),
+ isNull(UserTable.timeDeleted),
+ isNull(WorkspaceTable.timeDeleted),
+ ),
+ ),
+ )
+ })
return redirect(`/workspace/${workspaces[0].id}`)
} catch {
return redirect("/auth/authorize")
diff --git a/packages/console/app/src/routes/workspace-picker.css b/packages/console/app/src/routes/workspace-picker.css
new file mode 100644
index 000000000..c22ced867
--- /dev/null
+++ b/packages/console/app/src/routes/workspace-picker.css
@@ -0,0 +1,184 @@
+[data-component="workspace-picker"] {
+ position: relative;
+ /* Override blue accent colors with neutral colors */
+ --color-accent: var(--color-border);
+ --color-accent-hover: var(--color-border);
+ --color-accent-active: var(--color-border);
+ --color-primary: var(--color-border);
+ --color-primary-hover: var(--color-border);
+ --color-primary-active: var(--color-border);
+ --color-primary-alpha-20: transparent;
+
+ [data-slot="trigger"] {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+ 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-sans);
+ cursor: pointer;
+ min-width: 200px;
+
+ span {
+ flex: 1;
+ text-align: left;
+ font-weight: 500;
+ }
+ }
+
+ [data-slot="chevron"] {
+ flex-shrink: 0;
+ color: var(--color-text-secondary);
+ }
+
+ [data-slot="dropdown"] button {
+ text-decoration: none !important;
+ }
+
+ /* Ensure text inside buttons has no underline */
+ [data-slot="dropdown"] button * {
+ text-decoration: none !important;
+ }
+
+ [data-slot="dropdown"] {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ right: 0;
+ z-index: 1000;
+ margin-top: var(--space-1);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ background-color: var(--color-bg);
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ max-height: 240px;
+ overflow-y: auto;
+
+ @media (prefers-color-scheme: dark) {
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ }
+ }
+
+ [data-slot="option"],
+ [data-slot="create-option"] {
+ width: 100%;
+ padding: var(--space-2-5) var(--space-3);
+ border: none;
+ background: none;
+ color: var(--color-text);
+ font-size: var(--font-size-sm);
+ font-family: var(--font-sans);
+ text-align: left;
+ cursor: pointer;
+ text-decoration: none;
+
+ &:hover {
+ background-color: var(--color-surface);
+ text-decoration: none;
+ }
+
+ &:focus {
+ text-decoration: none;
+ }
+
+ &:active {
+ text-decoration: none;
+ }
+
+ &:first-child {
+ border-top-left-radius: var(--border-radius-sm);
+ border-top-right-radius: var(--border-radius-sm);
+ }
+
+ &:last-child {
+ border-bottom-left-radius: var(--border-radius-sm);
+ border-bottom-right-radius: var(--border-radius-sm);
+ }
+ }
+
+ [data-slot="option"][data-selected="true"] {
+ background-color: transparent;
+ color: var(--color-text);
+ }
+
+ [data-slot="create-option"] {
+ color: var(--color-text-secondary);
+ font-weight: 500;
+ }
+
+ [data-slot="create-form"] {
+ margin-top: var(--space-4);
+ padding: var(--space-4);
+ border: 1px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
+ background-color: var(--color-surface);
+ }
+
+ [data-slot="create-input-group"] {
+ display: flex;
+ gap: var(--space-2);
+ align-items: center;
+
+ @media (max-width: 30rem) {
+ flex-direction: column;
+ align-items: stretch;
+ }
+ }
+
+ [data-slot="create-input"] {
+ flex: 1;
+ padding: var(--space-2-5) 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-sans);
+
+ &:focus {
+ outline: none;
+ border-color: var(--color-border);
+ box-shadow: none;
+ }
+
+ &::placeholder {
+ color: var(--color-text-muted);
+ }
+ }
+
+ button[type="submit"],
+ button[type="button"] {
+ padding: var(--space-2-5) var(--space-4);
+ background-color: var(--color-bg);
+ color: var(--color-text);
+ font-size: var(--font-size-sm);
+ font-family: var(--font-sans);
+ font-weight: 500;
+ cursor: pointer;
+ white-space: nowrap;
+
+ &:focus {
+ outline: none;
+ box-shadow: none;
+ }
+
+ &:active {
+ transform: translateY(1px);
+ }
+
+ &[data-color="primary"] {
+ background-color: var(--color-text-secondary);
+ border-color: var(--color-text-secondary);
+ color: var(--color-bg);
+ }
+
+ @media (max-width: 30rem) {
+ flex: 1;
+ }
+ }
+} \ No newline at end of file
diff --git a/packages/console/app/src/routes/workspace-picker.tsx b/packages/console/app/src/routes/workspace-picker.tsx
new file mode 100644
index 000000000..181826335
--- /dev/null
+++ b/packages/console/app/src/routes/workspace-picker.tsx
@@ -0,0 +1,144 @@
+import { query, useParams, action, createAsync, redirect } from "@solidjs/router"
+import { For, Show, createEffect, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import { withActor } from "~/context/auth.withActor"
+import { Actor } from "@opencode-ai/console-core/actor.js"
+import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
+import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
+import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
+import { Workspace } from "@opencode-ai/console-core/workspace.js"
+import "./workspace-picker.css"
+
+const getWorkspaces = query(async () => {
+ "use server"
+ return withActor(async () => {
+ return Database.transaction((tx) =>
+ tx
+ .select({
+ id: WorkspaceTable.id,
+ name: WorkspaceTable.name,
+ slug: WorkspaceTable.slug,
+ })
+ .from(UserTable)
+ .innerJoin(WorkspaceTable, eq(UserTable.workspaceID, WorkspaceTable.id))
+ .where(and(eq(UserTable.accountID, Actor.account()), isNull(WorkspaceTable.timeDeleted))),
+ )
+ })
+}, "workspaces")
+
+const createWorkspace = action(async (form: FormData) => {
+ "use server"
+ const name = form.get("workspaceName") as string
+ if (name?.trim()) {
+ return withActor(async () => {
+ const workspaceID = await Workspace.create({ name: name.trim() })
+ return redirect(`/workspace/${workspaceID}`)
+ })
+ }
+}, "createWorkspace")
+
+export function WorkspacePicker() {
+ const params = useParams()
+ const workspaces = createAsync(() => getWorkspaces())
+ const [store, setStore] = createStore({
+ showForm: false,
+ showDropdown: false,
+ })
+ let dropdownRef: HTMLDivElement | undefined
+
+ const currentWorkspace = () => {
+ const ws = workspaces()?.find((w) => w.id === params.id)
+ return ws ? ws.name : "Select workspace"
+ }
+
+ const handleWorkspaceNew = () => {
+ setStore({ showForm: true, showDropdown: false })
+ }
+
+ const handleSelectWorkspace = (workspaceID: string) => {
+ if (workspaceID === params.id) {
+ setStore("showDropdown", false)
+ return
+ }
+
+ window.location.href = `/workspace/${workspaceID}`
+ }
+
+ // Reset signals when workspace ID changes
+ createEffect(() => {
+ params.id
+ setStore("showForm", false)
+ setStore("showDropdown", false)
+ })
+
+ createEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
+ setStore("showDropdown", false)
+ }
+ }
+
+ document.addEventListener("click", handleClickOutside)
+ onCleanup(() => document.removeEventListener("click", handleClickOutside))
+ })
+
+ return (
+ <div data-component="workspace-picker">
+ <div ref={dropdownRef}>
+ <div data-slot="trigger" onClick={() => setStore("showDropdown", !store.showDropdown)}>
+ <span>{currentWorkspace()}</span>
+ <svg data-slot="chevron" width="12" height="8" viewBox="0 0 12 8" fill="none">
+ <path
+ d="M1 1L6 6L11 1"
+ stroke="currentColor"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ />
+ </svg>
+ </div>
+
+ <Show when={store.showDropdown}>
+ <div data-slot="dropdown">
+ <For each={workspaces()}>
+ {(workspace) => (
+ <button
+ data-slot="option"
+ data-selected={workspace.id === params.id}
+ type="button"
+ onClick={() => handleSelectWorkspace(workspace.id)}
+ >
+ {workspace.name || workspace.slug}
+ </button>
+ )}
+ </For>
+ <button data-slot="create-option" type="button" onClick={() => handleWorkspaceNew()}>
+ + Create New Workspace
+ </button>
+ </div>
+ </Show>
+ </div>
+
+ <Show when={store.showForm}>
+ <form data-slot="create-form" action={createWorkspace} method="post">
+ <div data-slot="create-input-group">
+ <input
+ data-slot="create-input"
+ type="text"
+ name="workspaceName"
+ placeholder="Enter workspace name"
+ required
+ autofocus
+ />
+ <button type="submit" data-color="primary">
+ Create
+ </button>
+ <button type="button" onClick={() => setStore("showForm", false)}>
+ Cancel
+ </button>
+ </div>
+ </form>
+ </Show>
+ </div>
+ )
+}
diff --git a/packages/console/app/src/routes/workspace.tsx b/packages/console/app/src/routes/workspace.tsx
index 8e42815f7..ac394f585 100644
--- a/packages/console/app/src/routes/workspace.tsx
+++ b/packages/console/app/src/routes/workspace.tsx
@@ -1,11 +1,14 @@
+import { Show } from "solid-js"
+import { getRequestEvent } from "solid-js/web"
+import { query, action, redirect, createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
import "./workspace.css"
import { useAuthSession } from "~/context/auth.session"
import { IconLogo } from "../component/icon"
+import { WorkspacePicker } from "./workspace-picker"
import { withActor } from "~/context/auth.withActor"
-import { query, action, redirect, createAsync, RouteSectionProps, useParams, A } from "@solidjs/router"
import { User } from "@opencode-ai/console-core/user.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
-import { getRequestEvent } from "solid-js/web"
+import { beta } from "~/lib/beta"
const getUserInfo = query(async (workspaceID: string) => {
"use server"
@@ -35,6 +38,7 @@ 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))
return (
<main data-page="workspace">
<header data-component="workspace-header">
@@ -44,6 +48,9 @@ export default function WorkspaceLayout(props: RouteSectionProps) {
</A>
</div>
<div data-slot="header-actions">
+ <Show when={isBeta()}>
+ <WorkspacePicker />
+ </Show>
<span data-slot="user">{userInfo()?.email}</span>
<form action={logout} method="post">
<button type="submit" formaction={logout}>
diff --git a/packages/console/app/src/routes/workspace/[id].tsx b/packages/console/app/src/routes/workspace/[id].tsx
index 6575371d4..2e59395b9 100644
--- a/packages/console/app/src/routes/workspace/[id].tsx
+++ b/packages/console/app/src/routes/workspace/[id].tsx
@@ -11,23 +11,23 @@ import { createAsync, query, useParams } from "@solidjs/router"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { withActor } from "~/context/auth.withActor"
import { User } from "@opencode-ai/console-core/user.js"
-import { Resource } from "@opencode-ai/console-resource"
+import { beta } from "~/lib/beta"
-const getUser = query(async (workspaceID: string) => {
+const getUserInfo = query(async (workspaceID: string) => {
"use server"
return withActor(async () => {
const actor = Actor.assert("user")
const user = await User.fromID(actor.properties.userID)
return {
isAdmin: user?.role === "admin",
- isBeta: Resource.App.stage === "production" ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y" : true,
}
}, workspaceID)
}, "user.get")
export default function () {
const params = useParams()
- const data = createAsync(() => getUser(params.id))
+ const userInfo = createAsync(() => getUserInfo(params.id))
+ const isBeta = createAsync(() => beta(params.id))
return (
<div data-page="workspace-[id]">
<section data-component="title-section">
@@ -44,15 +44,15 @@ export default function () {
<div data-slot="sections">
<NewUserSection />
<KeySection />
- <Show when={data()?.isAdmin}>
- <Show when={data()?.isBeta}>
+ <Show when={userInfo()?.isAdmin}>
+ <Show when={isBeta()}>
<MemberSection />
</Show>
<BillingSection />
<MonthlyLimitSection />
</Show>
<UsageSection />
- <Show when={data()?.isAdmin}>
+ <Show when={userInfo()?.isAdmin}>
<PaymentSection />
</Show>
</div>
diff --git a/packages/console/core/src/account.ts b/packages/console/core/src/account.ts
index cd1eed4b1..f246c8e1a 100644
--- a/packages/console/core/src/account.ts
+++ b/packages/console/core/src/account.ts
@@ -1,12 +1,9 @@
import { z } from "zod"
-import { and, eq, getTableColumns, isNull } from "drizzle-orm"
+import { eq } from "drizzle-orm"
import { fn } from "./util/fn"
import { Database } from "./drizzle"
import { Identifier } from "./identifier"
import { AccountTable } from "./schema/account.sql"
-import { Actor } from "./actor"
-import { WorkspaceTable } from "./schema/workspace.sql"
-import { UserTable } from "./schema/user.sql"
export namespace Account {
export const create = fn(
@@ -46,16 +43,4 @@ export namespace Account {
.then((rows) => rows[0])
}),
)
-
- export const workspaces = async () => {
- const actor = Actor.assert("account")
- return Database.transaction(async (tx) =>
- tx
- .select(getTableColumns(WorkspaceTable))
- .from(WorkspaceTable)
- .innerJoin(UserTable, eq(UserTable.workspaceID, WorkspaceTable.id))
- .where(and(eq(UserTable.accountID, actor.properties.accountID), isNull(WorkspaceTable.timeDeleted)))
- .execute(),
- )
- }
}
diff --git a/packages/console/core/src/actor.ts b/packages/console/core/src/actor.ts
index 0d13f7216..ae11335f8 100644
--- a/packages/console/core/src/actor.ts
+++ b/packages/console/core/src/actor.ts
@@ -20,6 +20,7 @@ export namespace Actor {
properties: {
userID: string
workspaceID: string
+ accountID: string
}
}
@@ -71,4 +72,12 @@ export namespace Actor {
}
throw new Error(`actor of type "${actor.type}" is not associated with a workspace`)
}
+
+ export function account() {
+ const actor = use()
+ if ("accountID" in actor.properties) {
+ return actor.properties.accountID
+ }
+ throw new Error(`actor of type "${actor.type}" is not associated with an account`)
+ }
}
diff --git a/packages/console/core/src/schema/workspace.sql.ts b/packages/console/core/src/schema/workspace.sql.ts
index 3b02d346d..979255428 100644
--- a/packages/console/core/src/schema/workspace.sql.ts
+++ b/packages/console/core/src/schema/workspace.sql.ts
@@ -1,4 +1,4 @@
-import { primaryKey, mysqlTable, uniqueIndex, varchar, boolean } from "drizzle-orm/mysql-core"
+import { primaryKey, mysqlTable, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { timestamps, ulid } from "../drizzle/types"
export const WorkspaceTable = mysqlTable(
diff --git a/packages/console/core/src/user.ts b/packages/console/core/src/user.ts
index e33e86cb0..d4a0da0f8 100644
--- a/packages/console/core/src/user.ts
+++ b/packages/console/core/src/user.ts
@@ -172,8 +172,6 @@ export namespace User {
),
),
)
-
- return invitations.length
})
export const updateRole = fn(
diff --git a/packages/console/core/src/workspace.ts b/packages/console/core/src/workspace.ts
index 87a08adb3..36d66e15a 100644
--- a/packages/console/core/src/workspace.ts
+++ b/packages/console/core/src/workspace.ts
@@ -9,34 +9,40 @@ import { WorkspaceTable } from "./schema/workspace.sql"
import { Key } from "./key"
export namespace Workspace {
- export const create = fn(z.void(), async () => {
- const account = Actor.assert("account")
- const workspaceID = Identifier.create("workspace")
- const userID = Identifier.create("user")
- await Database.transaction(async (tx) => {
- await tx.insert(WorkspaceTable).values({
- id: workspaceID,
+ export const create = fn(
+ z.object({
+ name: z.string(),
+ }),
+ async ({ name }) => {
+ const account = Actor.assert("account")
+ const workspaceID = Identifier.create("workspace")
+ const userID = Identifier.create("user")
+ await Database.transaction(async (tx) => {
+ await tx.insert(WorkspaceTable).values({
+ id: workspaceID,
+ name,
+ })
+ await tx.insert(UserTable).values({
+ workspaceID,
+ id: userID,
+ accountID: account.properties.accountID,
+ name: "",
+ role: "admin",
+ })
+ await tx.insert(BillingTable).values({
+ workspaceID,
+ id: Identifier.create("billing"),
+ balance: 0,
+ })
})
- await tx.insert(UserTable).values({
- workspaceID,
- id: userID,
- accountID: account.properties.accountID,
- name: "",
- role: "admin",
- })
- await tx.insert(BillingTable).values({
- workspaceID,
- id: Identifier.create("billing"),
- balance: 0,
- })
- })
- await Actor.provide(
- "system",
- {
- workspaceID,
- },
- () => Key.create({ userID, name: "Default API Key" }),
- )
- return workspaceID
- })
+ await Actor.provide(
+ "system",
+ {
+ workspaceID,
+ },
+ () => Key.create({ userID, name: "Default API Key" }),
+ )
+ return workspaceID
+ },
+ )
}
diff --git a/packages/console/function/src/auth.ts b/packages/console/function/src/auth.ts
index 91f9b5045..e991e8c22 100644
--- a/packages/console/function/src/auth.ts
+++ b/packages/console/function/src/auth.ts
@@ -11,6 +11,9 @@ import { Workspace } from "@opencode-ai/console-core/workspace.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { Resource } from "@opencode-ai/console-resource"
import { User } from "@opencode-ai/console-core/user.js"
+import { and, Database, eq, isNull } from "@opencode-ai/console-core/drizzle/index.js"
+import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
+import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
type Env = {
AuthStorage: KVNamespace
@@ -123,9 +126,22 @@ export default {
})
}
await Actor.provide("account", { accountID, email }, async () => {
- const workspaceCount = await User.joinInvitedWorkspaces()
- if (workspaceCount === 0) {
- await Workspace.create()
+ await User.joinInvitedWorkspaces()
+ const workspaces = await Database.transaction(async (tx) =>
+ tx
+ .select({ id: WorkspaceTable.id })
+ .from(WorkspaceTable)
+ .innerJoin(UserTable, eq(UserTable.workspaceID, WorkspaceTable.id))
+ .where(
+ and(
+ eq(UserTable.accountID, accountID),
+ isNull(UserTable.timeDeleted),
+ isNull(WorkspaceTable.timeDeleted),
+ ),
+ ),
+ )
+ if (workspaces.length === 0) {
+ await Workspace.create({ name: "Default" })
}
})
return ctx.subject("account", accountID, { accountID, email })