mirror of
https://github.com/aljazceru/opencode.git
synced 2025-12-23 10:44:21 +01:00
wip: zen
This commit is contained in:
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -2,32 +2,49 @@ 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 removeMember = action(async (form: FormData) => {
|
||||
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"
|
||||
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" }
|
||||
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,
|
||||
),
|
||||
{ revalidate: listMembers.key },
|
||||
)
|
||||
}, "member.remove")
|
||||
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"
|
||||
@@ -38,34 +55,105 @@ const inviteMember = action(async (form: FormData) => {
|
||||
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,
|
||||
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,
|
||||
}),
|
||||
),
|
||||
})
|
||||
.then((data) => ({ error: undefined, data }))
|
||||
.catch((e) => ({ error: e.message as string })),
|
||||
),
|
||||
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 listMembers = query(async (workspaceID: string) => {
|
||||
const removeMember = action(async (form: FormData) => {
|
||||
"use server"
|
||||
return withActor(
|
||||
() => Database.use((tx) => tx.select().from(UserTable).where(eq(UserTable.workspaceID, workspaceID))),
|
||||
workspaceID,
|
||||
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" }
|
||||
return json(
|
||||
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.list")
|
||||
}, "member.remove")
|
||||
|
||||
const updateMemberRole = action(async (form: FormData) => {
|
||||
"use server"
|
||||
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(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.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>
|
||||
|
||||
Reference in New Issue
Block a user