diff options
| author | Frank <[email protected]> | 2025-10-06 16:15:10 -0400 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-10-06 17:13:19 -0400 |
| commit | 9e8fd16e6e4154ae0bccff8342e4b0c7780d8db8 (patch) | |
| tree | c9f936bfcaf7cf7f275334e869738c87989a2362 | |
| parent | 1b17d8070bcddeddaea3dea403f031a161539901 (diff) | |
| download | opencode-9e8fd16e6e4154ae0bccff8342e4b0c7780d8db8.tar.gz opencode-9e8fd16e6e4154ae0bccff8342e4b0c7780d8db8.zip | |
wip: zen
| -rw-r--r-- | packages/console/app/src/context/auth.ts | 1 | ||||
| -rw-r--r-- | packages/console/app/src/lib/beta.ts | 7 | ||||
| -rw-r--r-- | packages/console/app/src/routes/auth/index.ts | 22 | ||||
| -rw-r--r-- | packages/console/app/src/routes/workspace-picker.css | 184 | ||||
| -rw-r--r-- | packages/console/app/src/routes/workspace-picker.tsx | 144 | ||||
| -rw-r--r-- | packages/console/app/src/routes/workspace.tsx | 11 | ||||
| -rw-r--r-- | packages/console/app/src/routes/workspace/[id].tsx | 14 | ||||
| -rw-r--r-- | packages/console/core/src/account.ts | 17 | ||||
| -rw-r--r-- | packages/console/core/src/actor.ts | 9 | ||||
| -rw-r--r-- | packages/console/core/src/schema/workspace.sql.ts | 2 | ||||
| -rw-r--r-- | packages/console/core/src/user.ts | 2 | ||||
| -rw-r--r-- | packages/console/core/src/workspace.ts | 64 | ||||
| -rw-r--r-- | packages/console/function/src/auth.ts | 22 |
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 }) |
