Frank 4 месяцев назад
Родитель
Сommit
9e8fd16e6e

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

+ 7 - 0
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")

+ 20 - 2
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")

+ 184 - 0
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;
+    }
+  }
+}

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

+ 9 - 2
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}>

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

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

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

+ 1 - 1
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(

+ 0 - 2
packages/console/core/src/user.ts

@@ -172,8 +172,6 @@ export namespace User {
         ),
       ),
     )
-
-    return invitations.length
   })
 
   export const updateRole = fn(

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

+ 19 - 3
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 })