diff options
| author | Frank <[email protected]> | 2025-10-01 19:34:37 -0400 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-10-01 19:34:37 -0400 |
| commit | 70da3a9399d3385b53f7831beb08f716b972860d (patch) | |
| tree | 6934fafb8558d426df88ca83d043eb3ac5910dcd /packages/console/app/src | |
| parent | 1024537b471c341581826b39c360d34d6c32404f (diff) | |
| download | opencode-70da3a9399d3385b53f7831beb08f716b972860d.tar.gz opencode-70da3a9399d3385b53f7831beb08f716b972860d.zip | |
wip: zen
Diffstat (limited to 'packages/console/app/src')
| -rw-r--r-- | packages/console/app/src/context/auth.ts | 16 | ||||
| -rw-r--r-- | packages/console/app/src/routes/workspace/[id].tsx | 41 | ||||
| -rw-r--r-- | packages/console/app/src/routes/workspace/member-section.tsx | 281 |
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> |
