summaryrefslogtreecommitdiffhomepage
path: root/packages/console/app/src
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-10-01 19:34:37 -0400
committerFrank <[email protected]>2025-10-01 19:34:37 -0400
commit70da3a9399d3385b53f7831beb08f716b972860d (patch)
tree6934fafb8558d426df88ca83d043eb3ac5910dcd /packages/console/app/src
parent1024537b471c341581826b39c360d34d6c32404f (diff)
downloadopencode-70da3a9399d3385b53f7831beb08f716b972860d.tar.gz
opencode-70da3a9399d3385b53f7831beb08f716b972860d.zip
wip: zen
Diffstat (limited to 'packages/console/app/src')
-rw-r--r--packages/console/app/src/context/auth.ts16
-rw-r--r--packages/console/app/src/routes/workspace/[id].tsx41
-rw-r--r--packages/console/app/src/routes/workspace/member-section.tsx281
3 files changed, 261 insertions, 77 deletions
diff --git a/packages/console/app/src/context/auth.ts b/packages/console/app/src/context/auth.ts
index 585cac3c6..7097787fb 100644
--- a/packages/console/app/src/context/auth.ts
+++ b/packages/console/app/src/context/auth.ts
@@ -1,5 +1,5 @@
import { getRequestEvent } from "solid-js/web"
-import { and, Database, eq, inArray } from "@opencode/console-core/drizzle/index.js"
+import { and, Database, eq, inArray, sql } from "@opencode/console-core/drizzle/index.js"
import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js"
import { UserTable } from "@opencode/console-core/schema/user.sql.js"
import { redirect } from "@solidjs/router"
@@ -54,8 +54,8 @@ export const getActor = async (workspace?: string): Promise<Actor.Info> => {
}
const accounts = Object.keys(auth.data.account ?? {})
if (accounts.length) {
- const result = await Database.transaction(async (tx) => {
- return await tx
+ const result = await Database.use((tx) =>
+ tx
.select({
user: UserTable,
})
@@ -65,9 +65,15 @@ export const getActor = async (workspace?: string): Promise<Actor.Info> => {
.where(and(inArray(AccountTable.id, accounts), eq(WorkspaceTable.id, workspace)))
.limit(1)
.execute()
- .then((x) => x[0])
- })
+ .then((x) => x[0]),
+ )
if (result) {
+ await Database.use((tx) =>
+ tx
+ .update(UserTable)
+ .set({ timeSeen: sql`now()` })
+ .where(eq(UserTable.id, result.user.id)),
+ )
return {
type: "user",
properties: {
diff --git a/packages/console/app/src/routes/workspace/[id].tsx b/packages/console/app/src/routes/workspace/[id].tsx
index 5257a8e97..df05b14b1 100644
--- a/packages/console/app/src/routes/workspace/[id].tsx
+++ b/packages/console/app/src/routes/workspace/[id].tsx
@@ -7,10 +7,33 @@ import { UsageSection } from "./usage-section"
import { KeySection } from "./key-section"
import { MemberSection } from "./member-section"
import { Show } from "solid-js"
-import { useParams } from "@solidjs/router"
+import { createAsync, query, useParams } from "@solidjs/router"
+import { Actor } from "@opencode/console-core/actor.js"
+import { withActor } from "~/context/auth.withActor"
+import { and, Database, eq } from "@opencode/console-core/drizzle/index.js"
+import { UserTable } from "@opencode/console-core/schema/user.sql.js"
+
+const getUser = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ const actor = Actor.use()
+ const isAdmin = await (async () => {
+ if (actor.type !== "user") return false
+ const role = await Database.use((tx) =>
+ tx
+ .select({ role: UserTable.role })
+ .from(UserTable)
+ .where(and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.id, actor.properties.userID))),
+ ).then((x) => x[0]?.role)
+ return role === "admin"
+ })()
+ return { isAdmin }
+ }, workspaceID)
+}, "user.get")
export default function () {
const params = useParams()
+ const data = createAsync(() => getUser(params.id))
return (
<div data-page="workspace-[id]">
<section data-component="title-section">
@@ -27,13 +50,17 @@ export default function () {
<div data-slot="sections">
<NewUserSection />
<KeySection />
- <Show when={isBeta(params.id)}>
- <MemberSection />
+ <Show when={data()?.isAdmin}>
+ <Show when={isBeta(params.id)}>
+ <MemberSection />
+ </Show>
+ <BillingSection />
+ <MonthlyLimitSection />
</Show>
- <BillingSection />
- <MonthlyLimitSection />
<UsageSection />
- <PaymentSection />
+ <Show when={data()?.isAdmin}>
+ <PaymentSection />
+ </Show>
</div>
</div>
)
@@ -43,6 +70,6 @@ export function isBeta(workspaceID: string) {
return [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // production
"wrk_01K4NFRR5P7FSYWH88307B4DDS", // dev
- "wrk_01K68M8J1KK0PJ39H59B1EGHP6", // frank
+ "wrk_01K6G7HBZ7C046A4XK01CVD0NS", // frank
].includes(workspaceID)
}
diff --git a/packages/console/app/src/routes/workspace/member-section.tsx b/packages/console/app/src/routes/workspace/member-section.tsx
index d0afd7eb9..0e3a101fd 100644
--- a/packages/console/app/src/routes/workspace/member-section.tsx
+++ b/packages/console/app/src/routes/workspace/member-section.tsx
@@ -2,11 +2,99 @@ import { json, query, action, useParams, createAsync, useSubmission } from "@sol
import { createEffect, createSignal, For, Show } from "solid-js"
import { withActor } from "~/context/auth.withActor"
import { createStore } from "solid-js/store"
-import { formatDateUTC, formatDateForTable } from "./common"
import styles from "./member-section.module.css"
-import { and, Database, eq, sql } from "@opencode/console-core/drizzle/index.js"
+import { and, Database, eq, isNull, sql } from "@opencode/console-core/drizzle/index.js"
import { UserTable, UserRole } from "@opencode/console-core/schema/user.sql.js"
import { Identifier } from "@opencode/console-core/identifier.js"
+import { Actor } from "@opencode/console-core/actor.js"
+import { AWS } from "@opencode/console-core/aws.js"
+
+const assertAdmin = async (workspaceID: string) => {
+ const actor = Actor.use()
+ if (actor.type !== "user") throw new Error(`Expected admin user, got ${actor.type}`)
+ const user = await Database.use((tx) =>
+ tx
+ .select()
+ .from(UserTable)
+ .where(and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.id, actor.properties.userID))),
+ ).then((x) => x[0])
+ if (user?.role !== "admin") throw new Error(`Expected admin user, got ${user?.role}`)
+ return actor
+}
+
+const assertNotSelf = (id: string) => {
+ const actor = Actor.use()
+ if (actor.type === "user" && actor.properties.userID === id) {
+ throw new Error(`Expected not self actor, got self actor`)
+ }
+ return actor
+}
+
+const listMembers = query(async (workspaceID: string) => {
+ "use server"
+ return withActor(async () => {
+ const actor = await assertAdmin(workspaceID)
+ return Database.use((tx) =>
+ tx
+ .select()
+ .from(UserTable)
+ .where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
+ ).then((members) => ({
+ members,
+ currentUserID: actor.properties.userID,
+ }))
+ }, workspaceID)
+}, "member.list")
+
+const inviteMember = action(async (form: FormData) => {
+ "use server"
+ const email = form.get("email")?.toString().trim()
+ if (!email) return { error: "Email is required" }
+ const workspaceID = form.get("workspaceID")?.toString()
+ if (!workspaceID) return { error: "Workspace ID is required" }
+ const role = form.get("role")?.toString() as (typeof UserRole)[number]
+ if (!role) return { error: "Role is required" }
+ return json(
+ await withActor(async () => {
+ await assertAdmin(workspaceID)
+ return Database.use((tx) =>
+ tx
+ .insert(UserTable)
+ .values({
+ id: Identifier.create("user"),
+ name: "",
+ email,
+ workspaceID,
+ role,
+ })
+ .then((data) => ({ error: undefined, data }))
+ .then(async (data) => {
+ const { render } = await import("@jsx-email/render")
+ const { InviteEmail } = await import("@opencode/console-mail/InviteEmail.jsx")
+ await AWS.sendEmail({
+ to: email,
+ subject: `You've been invited to join the ${workspaceID} workspace on OpenCode Zen`,
+ body: render(
+ // @ts-ignore
+ InviteEmail({
+ assetsUrl: `https://opencode.ai/email`,
+ workspace: workspaceID,
+ }),
+ ),
+ })
+ return data
+ })
+ .catch((e) => {
+ let error = e.message
+ if (error.match(/Duplicate entry '.*' for key 'user.user_email'/))
+ error = "A user with this email has already been invited."
+ return { error }
+ }),
+ )
+ }, workspaceID),
+ { revalidate: listMembers.key },
+ )
+}, "member.create")
const removeMember = action(async (form: FormData) => {
"use server"
@@ -15,57 +103,57 @@ const removeMember = action(async (form: FormData) => {
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(
- await withActor(
- () =>
- Database.use((tx) =>
- tx
- .update(UserTable)
- .set({ timeDeleted: sql`now()` })
- .where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, workspaceID))),
- ),
- workspaceID,
- ),
+ await withActor(async () => {
+ await assertAdmin(workspaceID)
+ assertNotSelf(id)
+ return Database.transaction(async (tx) => {
+ const email = await tx
+ .select({ email: UserTable.email })
+ .from(UserTable)
+ .where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, workspaceID)))
+ .execute()
+ .then((rows) => rows[0].email)
+ if (!email) return { error: "User not found" }
+ await tx
+ .update(UserTable)
+ .set({
+ oldEmail: email,
+ email: null,
+ timeDeleted: sql`now()`,
+ })
+ .where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, workspaceID)))
+ })
+ .then(() => ({ error: undefined }))
+ .catch((e) => ({ error: e.message as string }))
+ }, workspaceID),
{ revalidate: listMembers.key },
)
}, "member.remove")
-const inviteMember = action(async (form: FormData) => {
+const updateMemberRole = action(async (form: FormData) => {
"use server"
- const email = form.get("email")?.toString().trim()
- if (!email) return { error: "Email is required" }
+ const id = form.get("id")?.toString()
+ if (!id) return { error: "ID is required" }
const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
const role = form.get("role")?.toString() as (typeof UserRole)[number]
if (!role) return { error: "Role is required" }
return json(
- await withActor(
- () =>
- Database.use((tx) =>
- tx
- .insert(UserTable)
- .values({
- id: Identifier.create("user"),
- name: "",
- email,
- workspaceID,
- role,
- })
- .then((data) => ({ error: undefined, data }))
- .catch((e) => ({ error: e.message as string })),
- ),
- workspaceID,
- ),
+ await withActor(async () => {
+ await assertAdmin(workspaceID)
+ if (role === "member") assertNotSelf(id)
+ return Database.use((tx) =>
+ tx
+ .update(UserTable)
+ .set({ role })
+ .where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, workspaceID)))
+ .then((data) => ({ error: undefined, data }))
+ .catch((e) => ({ error: e.message as string })),
+ )
+ }, workspaceID),
{ revalidate: listMembers.key },
)
-}, "member.create")
-
-const listMembers = query(async (workspaceID: string) => {
- "use server"
- return withActor(
- () => Database.use((tx) => tx.select().from(UserTable).where(eq(UserTable.workspaceID, workspaceID))),
- workspaceID,
- )
-}, "member.list")
+}, "member.updateRole")
export function MemberCreateForm() {
const params = useParams()
@@ -144,9 +232,89 @@ export function MemberCreateForm() {
)
}
+function MemberRow(props: { member: any; workspaceID: string; currentUserID: string | null }) {
+ const [editing, setEditing] = createSignal(false)
+ const submission = useSubmission(updateMemberRole)
+ const isCurrentUser = () => props.currentUserID === props.member.id
+
+ createEffect(() => {
+ if (!submission.pending && submission.result && !submission.result.error) {
+ setEditing(false)
+ }
+ })
+
+ return (
+ <Show
+ when={editing()}
+ fallback={
+ <tr>
+ <td data-slot="member-email">{props.member.email}</td>
+ <td data-slot="member-role">{props.member.role}</td>
+ <Show when={!props.member.timeSeen} fallback={<td data-slot="member-joined"></td>}>
+ <td data-slot="member-joined">invited</td>
+ </Show>
+ <td data-slot="member-actions">
+ <button data-color="ghost" onClick={() => setEditing(true)}>
+ Edit
+ </button>
+ <Show when={!isCurrentUser()}>
+ <form action={removeMember} method="post">
+ <input type="hidden" name="id" value={props.member.id} />
+ <input type="hidden" name="workspaceID" value={props.workspaceID} />
+ <button data-color="ghost">Delete</button>
+ </form>
+ </Show>
+ </td>
+ </tr>
+ }
+ >
+ <tr>
+ <td colspan="4">
+ <form action={updateMemberRole} method="post">
+ <div data-slot="edit-member-email">{props.member.email}</div>
+ <input type="hidden" name="id" value={props.member.id} />
+ <input type="hidden" name="workspaceID" value={props.workspaceID} />
+ <Show when={!isCurrentUser()} fallback={<div data-slot="current-user-role">Role: {props.member.role}</div>}>
+ <div data-slot="role-selector">
+ <label>
+ <input type="radio" name="role" value="admin" checked={props.member.role === "admin"} />
+ <div>
+ <strong>Admin</strong>
+ <p>Can manage models, members, and billing</p>
+ </div>
+ </label>
+ <label>
+ <input type="radio" name="role" value="member" checked={props.member.role === "member"} />
+ <div>
+ <strong>Member</strong>
+ <p>Can only generate API keys for themselves</p>
+ </div>
+ </label>
+ </div>
+ </Show>
+ <Show when={submission.result && submission.result.error}>
+ {(err) => <div data-slot="form-error">{err()}</div>}
+ </Show>
+ <div data-slot="form-actions">
+ <button type="button" data-color="ghost" onClick={() => setEditing(false)}>
+ Cancel
+ </button>
+ <Show when={!isCurrentUser()}>
+ <button type="submit" data-color="primary" disabled={submission.pending}>
+ {submission.pending ? "Saving..." : "Save"}
+ </button>
+ </Show>
+ </div>
+ </form>
+ </td>
+ </tr>
+ </Show>
+ )
+}
+
export function MemberSection() {
const params = useParams()
- const members = createAsync(() => listMembers(params.id))
+ const data = createAsync(() => listMembers(params.id))
return (
<section class={styles.root}>
@@ -157,7 +325,7 @@ export function MemberSection() {
<MemberCreateForm />
<div data-slot="members-table">
<Show
- when={members()?.length}
+ when={data()?.members.length}
fallback={
<div data-component="empty-state">
<p>Invite a member to your workspace</p>
@@ -169,32 +337,15 @@ export function MemberSection() {
<tr>
<th>Email</th>
<th>Role</th>
- <th>Joined</th>
+ <th></th>
<th></th>
</tr>
</thead>
<tbody>
- <For each={members()!}>
- {(member) => {
- return (
- <tr>
- <td data-slot="member-email">{member.email}</td>
- <td data-slot="member-role">{member.role}</td>
- <Show when={member.timeSeen} fallback={<td data-slot="member-joined">invited</td>}>
- <td data-slot="member-joined" title={formatDateUTC(member.timeSeen!)}>
- {formatDateForTable(member.timeSeen!)}
- </td>
- </Show>
- <td data-slot="member-actions">
- <form action={removeMember} method="post">
- <input type="hidden" name="id" value={member.id} />
- <input type="hidden" name="workspaceID" value={params.id} />
- <button data-color="ghost">Delete</button>
- </form>
- </td>
- </tr>
- )
- }}
+ <For each={data()!.members}>
+ {(member) => (
+ <MemberRow member={member} workspaceID={params.id} currentUserID={data()!.currentUserID} />
+ )}
</For>
</tbody>
</table>