summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app/src
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 /packages/console/app/src
parent1b17d8070bcddeddaea3dea403f031a161539901 (diff)
downloadopencode-9e8fd16e6e4154ae0bccff8342e4b0c7780d8db8.tar.gz
opencode-9e8fd16e6e4154ae0bccff8342e4b0c7780d8db8.zip
wip: zen
Diffstat (limited to 'packages/console/app/src')
-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
7 files changed, 372 insertions, 11 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>