This commit is contained in:
Frank
2025-10-08 18:59:41 -04:00
parent d18b6673e6
commit 5b1fd7e539
7 changed files with 79 additions and 72 deletions

View File

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

View File

@@ -48,10 +48,12 @@ export default function () {
<div data-slot="sections"> <div data-slot="sections">
<NewUserSection /> <NewUserSection />
<KeySection /> <KeySection />
<Show when={isBeta()}>
<MemberSection />
</Show>
<Show when={userInfo()?.isAdmin}> <Show when={userInfo()?.isAdmin}>
<Show when={isBeta()}> <Show when={isBeta()}>
<SettingsSection /> <SettingsSection />
<MemberSection />
<ModelSection /> <ModelSection />
<ProviderSection /> <ProviderSection />
</Show> </Show>

View File

@@ -7,11 +7,6 @@ import { createStore } from "solid-js/store"
import { formatDateUTC, formatDateForTable } from "./common" import { formatDateUTC, formatDateForTable } from "./common"
import styles from "./key-section.module.css" import styles from "./key-section.module.css"
import { Actor } from "@opencode-ai/console-core/actor.js" import { Actor } from "@opencode-ai/console-core/actor.js"
import { and, Database, eq, isNull, sql } from "@opencode-ai/console-core/drizzle/index.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { AccountTable } from "@opencode-ai/console-core/schema/account.sql.js"
import { User } from "@opencode-ai/console-core/user.js"
const removeKey = action(async (form: FormData) => { const removeKey = action(async (form: FormData) => {
"use server" "use server"

View File

@@ -10,10 +10,10 @@ import { User } from "@opencode-ai/console-core/user.js"
const listMembers = query(async (workspaceID: string) => { const listMembers = query(async (workspaceID: string) => {
"use server" "use server"
return withActor(async () => { return withActor(async () => {
const actor = Actor.assert("user")
return { return {
members: await User.list(), members: await User.list(),
currentUserID: actor.properties.userID, actorID: Actor.userID(),
actorRole: Actor.userRole(),
} }
}, workspaceID) }, workspaceID)
}, "member.list") }, "member.list")
@@ -158,10 +158,11 @@ export function MemberCreateForm() {
) )
} }
function MemberRow(props: { member: any; workspaceID: string; currentUserID: string | null }) { function MemberRow(props: { member: any; workspaceID: string; actorID: string; actorRole: string }) {
const [editing, setEditing] = createSignal(false) const [editing, setEditing] = createSignal(false)
const submission = useSubmission(updateMember) const submission = useSubmission(updateMember)
const isCurrentUser = () => props.currentUserID === props.member.id const isCurrentUser = () => props.actorID === props.member.id
const isAdmin = () => props.actorRole === "admin"
createEffect(() => { createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) { if (!submission.pending && submission.result && !submission.result.error) {
@@ -200,10 +201,9 @@ function MemberRow(props: { member: any; workspaceID: string; currentUserID: str
<td data-slot="member-email">{props.member.accountEmail ?? props.member.email}</td> <td data-slot="member-email">{props.member.accountEmail ?? props.member.email}</td>
<td data-slot="member-role">{props.member.role}</td> <td data-slot="member-role">{props.member.role}</td>
<td data-slot="member-usage">{getUsageDisplay()}</td> <td data-slot="member-usage">{getUsageDisplay()}</td>
<Show when={!props.member.timeSeen} fallback={<td data-slot="member-joined"></td>}> <td data-slot="member-joined">{props.member.timeSeen ? "" : "invited"}</td>
<td data-slot="member-joined">invited</td>
</Show>
<td data-slot="member-actions"> <td data-slot="member-actions">
<Show when={isAdmin()}>
<button data-color="ghost" onClick={() => setEditing(true)}> <button data-color="ghost" onClick={() => setEditing(true)}>
Edit Edit
</button> </button>
@@ -214,6 +214,7 @@ function MemberRow(props: { member: any; workspaceID: string; currentUserID: str
<button data-color="ghost">Delete</button> <button data-color="ghost">Delete</button>
</form> </form>
</Show> </Show>
</Show>
</td> </td>
</tr> </tr>
} }
@@ -293,18 +294,11 @@ export function MemberSection() {
<section class={styles.root}> <section class={styles.root}>
<div data-slot="section-title"> <div data-slot="section-title">
<h2>Members</h2> <h2>Members</h2>
<p>Manage your members for accessing opencode services.</p>
</div> </div>
<Show when={data()?.actorRole === "admin"}>
<MemberCreateForm /> <MemberCreateForm />
</Show>
<div data-slot="members-table"> <div data-slot="members-table">
<Show
when={data()?.members.length}
fallback={
<div data-component="empty-state">
<p>Invite a member to your workspace</p>
</div>
}
>
<table data-slot="members-table-element"> <table data-slot="members-table-element">
<thead> <thead>
<tr> <tr>
@@ -316,14 +310,18 @@ export function MemberSection() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<For each={data()!.members}> <For each={data()?.members || []}>
{(member) => ( {(member) => (
<MemberRow member={member} workspaceID={params.id} currentUserID={data()!.currentUserID} /> <MemberRow
member={member}
workspaceID={params.id}
actorID={data()!.actorID}
actorRole={data()!.actorRole}
/>
)} )}
</For> </For>
</tbody> </tbody>
</table> </table>
</Show>
</div> </div>
</section> </section>
) )

View File

@@ -1,4 +1,5 @@
import { Context } from "./context" import { Context } from "./context"
import { UserRole } from "./schema/user.sql"
import { Log } from "./util/log" import { Log } from "./util/log"
export namespace Actor { export namespace Actor {
@@ -21,6 +22,7 @@ export namespace Actor {
userID: string userID: string
workspaceID: string workspaceID: string
accountID: string accountID: string
role: (typeof UserRole)[number]
} }
} }
@@ -80,4 +82,12 @@ export namespace Actor {
} }
throw new Error(`actor of type "${actor.type}" is not associated with an account`) throw new Error(`actor of type "${actor.type}" is not associated with an account`)
} }
export function userID() {
return Actor.assert("user").properties.userID
}
export function userRole() {
return Actor.assert("user").properties.role
}
} }

View File

@@ -10,8 +10,6 @@ import { User } from "./user"
export namespace Key { export namespace Key {
export const list = fn(z.void(), async () => { export const list = fn(z.void(), async () => {
const userID = Actor.assert("user").properties.userID
const user = await User.fromID(userID)
const keys = await Database.use((tx) => const keys = await Database.use((tx) =>
tx tx
.select({ .select({
@@ -30,7 +28,7 @@ export namespace Key {
...[ ...[
eq(KeyTable.workspaceID, Actor.workspace()), eq(KeyTable.workspaceID, Actor.workspace()),
isNull(KeyTable.timeDeleted), isNull(KeyTable.timeDeleted),
...(user.role === "admin" ? [] : [eq(KeyTable.userID, userID)]), ...(Actor.userRole() === "admin" ? [] : [eq(KeyTable.userID, Actor.userID())]),
], ],
), ),
) )
@@ -39,7 +37,7 @@ export namespace Key {
// only return value for user's keys // only return value for user's keys
return keys.map((key) => ({ return keys.map((key) => ({
...key, ...key,
key: key.userID === userID ? key.key : undefined, key: key.userID === Actor.userID() ? key.key : undefined,
keyDisplay: `${key.key.slice(0, 7)}...${key.key.slice(-4)}`, keyDisplay: `${key.key.slice(0, 7)}...${key.key.slice(-4)}`,
})) }))
}) })
@@ -78,14 +76,22 @@ export namespace Key {
) )
export const remove = fn(z.object({ id: z.string() }), async (input) => { export const remove = fn(z.object({ id: z.string() }), async (input) => {
const workspace = Actor.workspace() // only admin can remove other user's keys
await Database.transaction((tx) => await Database.use((tx) =>
tx tx
.update(KeyTable) .update(KeyTable)
.set({ .set({
timeDeleted: sql`now()`, timeDeleted: sql`now()`,
}) })
.where(and(eq(KeyTable.id, input.id), eq(KeyTable.workspaceID, workspace))), .where(
and(
...[
eq(KeyTable.id, input.id),
eq(KeyTable.workspaceID, Actor.workspace()),
...(Actor.userRole() === "admin" ? [] : [eq(KeyTable.userID, Actor.userID())]),
],
),
),
) )
}) })
} }

View File

@@ -1,5 +1,5 @@
import { z } from "zod" import { z } from "zod"
import { and, eq, getTableColumns, inArray, isNull, or, sql } from "drizzle-orm" import { and, eq, getTableColumns, isNull, sql } from "drizzle-orm"
import { fn } from "./util/fn" import { fn } from "./util/fn"
import { Database } from "./drizzle" import { Database } from "./drizzle"
import { UserRole, UserTable } from "./schema/user.sql" import { UserRole, UserTable } from "./schema/user.sql"
@@ -13,20 +13,15 @@ import { Key } from "./key"
import { KeyTable } from "./schema/key.sql" import { KeyTable } from "./schema/key.sql"
export namespace User { export namespace User {
const assertAdmin = async () => { const assertAdmin = () => {
const actor = Actor.assert("user") if (Actor.userRole() === "admin") return
const user = await User.fromID(actor.properties.userID) throw new Error(`Expected admin user, got ${Actor.userRole()}`)
if (user?.role !== "admin") {
throw new Error(`Expected admin user, got ${user?.role}`)
}
} }
const assertNotSelf = (id: string) => { const assertNotSelf = (id: string) => {
const actor = Actor.assert("user") if (Actor.userID() !== id) return
if (actor.properties.userID === id) {
throw new Error(`Expected not self actor, got self actor`) throw new Error(`Expected not self actor, got self actor`)
} }
}
export const list = fn(z.void(), () => export const list = fn(z.void(), () =>
Database.use((tx) => Database.use((tx) =>
@@ -70,7 +65,7 @@ export namespace User {
role: z.enum(UserRole), role: z.enum(UserRole),
}), }),
async ({ email, role }) => { async ({ email, role }) => {
await assertAdmin() assertAdmin()
const workspaceID = Actor.workspace() const workspaceID = Actor.workspace()
// create user // create user
@@ -181,7 +176,7 @@ export namespace User {
monthlyLimit: z.number().nullable(), monthlyLimit: z.number().nullable(),
}), }),
async ({ id, role, monthlyLimit }) => { async ({ id, role, monthlyLimit }) => {
await assertAdmin() assertAdmin()
if (role === "member") assertNotSelf(id) if (role === "member") assertNotSelf(id)
return await Database.use((tx) => return await Database.use((tx) =>
tx tx
@@ -193,7 +188,7 @@ export namespace User {
) )
export const remove = fn(z.string(), async (id) => { export const remove = fn(z.string(), async (id) => {
await assertAdmin() assertAdmin()
assertNotSelf(id) assertNotSelf(id)
return await Database.use((tx) => return await Database.use((tx) =>