This commit is contained in:
Frank
2025-10-06 16:15:10 -04:00
parent 1b17d8070b
commit 9e8fd16e6e
13 changed files with 437 additions and 62 deletions

View File

@@ -73,6 +73,7 @@ export const getActor = async (workspace?: string): Promise<Actor.Info> => {
properties: { properties: {
userID: user.id, userID: user.id,
workspaceID: user.workspaceID, workspaceID: user.workspaceID,
accountID: user.accountID,
}, },
} }
} }

View File

@@ -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")

View File

@@ -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 { redirect } from "@solidjs/router"
import type { APIEvent } from "@solidjs/start/server" import type { APIEvent } from "@solidjs/start/server"
import { withActor } from "~/context/auth.withActor" import { withActor } from "~/context/auth.withActor"
export async function GET(input: APIEvent) { export async function GET(input: APIEvent) {
try { 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}`) return redirect(`/workspace/${workspaces[0].id}`)
} catch { } catch {
return redirect("/auth/authorize") return redirect("/auth/authorize")

View File

@@ -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;
}
}
}

View File

@@ -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>
)
}

View File

@@ -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 "./workspace.css"
import { useAuthSession } from "~/context/auth.session" import { useAuthSession } from "~/context/auth.session"
import { IconLogo } from "../component/icon" import { IconLogo } from "../component/icon"
import { WorkspacePicker } from "./workspace-picker"
import { withActor } from "~/context/auth.withActor" 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 { User } from "@opencode-ai/console-core/user.js"
import { Actor } from "@opencode-ai/console-core/actor.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) => { const getUserInfo = query(async (workspaceID: string) => {
"use server" "use server"
@@ -35,6 +38,7 @@ const logout = action(async () => {
export default function WorkspaceLayout(props: RouteSectionProps) { export default function WorkspaceLayout(props: RouteSectionProps) {
const params = useParams() const params = useParams()
const userInfo = createAsync(() => getUserInfo(params.id)) const userInfo = createAsync(() => getUserInfo(params.id))
const isBeta = createAsync(() => beta(params.id))
return ( return (
<main data-page="workspace"> <main data-page="workspace">
<header data-component="workspace-header"> <header data-component="workspace-header">
@@ -44,6 +48,9 @@ export default function WorkspaceLayout(props: RouteSectionProps) {
</A> </A>
</div> </div>
<div data-slot="header-actions"> <div data-slot="header-actions">
<Show when={isBeta()}>
<WorkspacePicker />
</Show>
<span data-slot="user">{userInfo()?.email}</span> <span data-slot="user">{userInfo()?.email}</span>
<form action={logout} method="post"> <form action={logout} method="post">
<button type="submit" formaction={logout}> <button type="submit" formaction={logout}>

View File

@@ -11,23 +11,23 @@ import { createAsync, query, useParams } from "@solidjs/router"
import { Actor } from "@opencode-ai/console-core/actor.js" import { Actor } from "@opencode-ai/console-core/actor.js"
import { withActor } from "~/context/auth.withActor" import { withActor } from "~/context/auth.withActor"
import { User } from "@opencode-ai/console-core/user.js" 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" "use server"
return withActor(async () => { return withActor(async () => {
const actor = Actor.assert("user") const actor = Actor.assert("user")
const user = await User.fromID(actor.properties.userID) const user = await User.fromID(actor.properties.userID)
return { return {
isAdmin: user?.role === "admin", isAdmin: user?.role === "admin",
isBeta: Resource.App.stage === "production" ? workspaceID === "wrk_01K46JDFR0E75SG2Q8K172KF3Y" : true,
} }
}, workspaceID) }, workspaceID)
}, "user.get") }, "user.get")
export default function () { export default function () {
const params = useParams() const params = useParams()
const data = createAsync(() => getUser(params.id)) const userInfo = createAsync(() => getUserInfo(params.id))
const isBeta = createAsync(() => beta(params.id))
return ( return (
<div data-page="workspace-[id]"> <div data-page="workspace-[id]">
<section data-component="title-section"> <section data-component="title-section">
@@ -44,15 +44,15 @@ export default function () {
<div data-slot="sections"> <div data-slot="sections">
<NewUserSection /> <NewUserSection />
<KeySection /> <KeySection />
<Show when={data()?.isAdmin}> <Show when={userInfo()?.isAdmin}>
<Show when={data()?.isBeta}> <Show when={isBeta()}>
<MemberSection /> <MemberSection />
</Show> </Show>
<BillingSection /> <BillingSection />
<MonthlyLimitSection /> <MonthlyLimitSection />
</Show> </Show>
<UsageSection /> <UsageSection />
<Show when={data()?.isAdmin}> <Show when={userInfo()?.isAdmin}>
<PaymentSection /> <PaymentSection />
</Show> </Show>
</div> </div>

View File

@@ -1,12 +1,9 @@
import { z } from "zod" import { z } from "zod"
import { and, eq, getTableColumns, isNull } from "drizzle-orm" import { eq } from "drizzle-orm"
import { fn } from "./util/fn" import { fn } from "./util/fn"
import { Database } from "./drizzle" import { Database } from "./drizzle"
import { Identifier } from "./identifier" import { Identifier } from "./identifier"
import { AccountTable } from "./schema/account.sql" 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 namespace Account {
export const create = fn( export const create = fn(
@@ -46,16 +43,4 @@ export namespace Account {
.then((rows) => rows[0]) .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(),
)
}
} }

View File

@@ -20,6 +20,7 @@ export namespace Actor {
properties: { properties: {
userID: string userID: string
workspaceID: 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`) 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`)
}
} }

View File

@@ -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" import { timestamps, ulid } from "../drizzle/types"
export const WorkspaceTable = mysqlTable( export const WorkspaceTable = mysqlTable(

View File

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

View File

@@ -9,13 +9,18 @@ import { WorkspaceTable } from "./schema/workspace.sql"
import { Key } from "./key" import { Key } from "./key"
export namespace Workspace { export namespace Workspace {
export const create = fn(z.void(), async () => { export const create = fn(
z.object({
name: z.string(),
}),
async ({ name }) => {
const account = Actor.assert("account") const account = Actor.assert("account")
const workspaceID = Identifier.create("workspace") const workspaceID = Identifier.create("workspace")
const userID = Identifier.create("user") const userID = Identifier.create("user")
await Database.transaction(async (tx) => { await Database.transaction(async (tx) => {
await tx.insert(WorkspaceTable).values({ await tx.insert(WorkspaceTable).values({
id: workspaceID, id: workspaceID,
name,
}) })
await tx.insert(UserTable).values({ await tx.insert(UserTable).values({
workspaceID, workspaceID,
@@ -38,5 +43,6 @@ export namespace Workspace {
() => Key.create({ userID, name: "Default API Key" }), () => Key.create({ userID, name: "Default API Key" }),
) )
return workspaceID return workspaceID
}) },
)
} }

View File

@@ -11,6 +11,9 @@ import { Workspace } from "@opencode-ai/console-core/workspace.js"
import { Actor } from "@opencode-ai/console-core/actor.js" import { Actor } from "@opencode-ai/console-core/actor.js"
import { Resource } from "@opencode-ai/console-resource" import { Resource } from "@opencode-ai/console-resource"
import { User } from "@opencode-ai/console-core/user.js" 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 = { type Env = {
AuthStorage: KVNamespace AuthStorage: KVNamespace
@@ -123,9 +126,22 @@ export default {
}) })
} }
await Actor.provide("account", { accountID, email }, async () => { await Actor.provide("account", { accountID, email }, async () => {
const workspaceCount = await User.joinInvitedWorkspaces() await User.joinInvitedWorkspaces()
if (workspaceCount === 0) { const workspaces = await Database.transaction(async (tx) =>
await Workspace.create() 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 }) return ctx.subject("account", accountID, { accountID, email })