summaryrefslogtreecommitdiffhomepage
path: root/packages/console/core/src
diff options
context:
space:
mode:
authorFrank <[email protected]>2025-10-02 13:58:38 -0400
committerFrank <[email protected]>2025-10-02 13:58:40 -0400
commita45fa7a93c7864ef7eed792949755b334a9b2524 (patch)
tree00fedb5631806de856ecf9e5b49c997521d9d31d /packages/console/core/src
parentae15c914556a51c042f37284d52945d6c480b37f (diff)
downloadopencode-a45fa7a93c7864ef7eed792949755b334a9b2524.tar.gz
opencode-a45fa7a93c7864ef7eed792949755b334a9b2524.zip
wip: zen
Diffstat (limited to 'packages/console/core/src')
-rw-r--r--packages/console/core/src/schema/user.sql.ts10
-rw-r--r--packages/console/core/src/user.ts178
-rw-r--r--packages/console/core/src/workspace.ts1
3 files changed, 179 insertions, 10 deletions
diff --git a/packages/console/core/src/schema/user.sql.ts b/packages/console/core/src/schema/user.sql.ts
index eaadb06d5..e1da69ee6 100644
--- a/packages/console/core/src/schema/user.sql.ts
+++ b/packages/console/core/src/schema/user.sql.ts
@@ -1,5 +1,5 @@
import { mysqlTable, uniqueIndex, varchar, int, mysqlEnum } from "drizzle-orm/mysql-core"
-import { timestamps, utc, workspaceColumns } from "../drizzle/types"
+import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types"
import { workspaceIndexes } from "./workspace.sql"
export const UserRole = ["admin", "member"] as const
@@ -9,6 +9,8 @@ export const UserTable = mysqlTable(
{
...workspaceColumns,
...timestamps,
+ accountID: ulid("account_id"),
+ oldAccountID: ulid("old_account_id"),
email: varchar("email", { length: 255 }),
oldEmail: varchar("old_email", { length: 255 }),
name: varchar("name", { length: 255 }).notNull(),
@@ -16,5 +18,9 @@ export const UserTable = mysqlTable(
color: int("color"),
role: mysqlEnum("role", UserRole).notNull(),
},
- (table) => [...workspaceIndexes(table), uniqueIndex("user_email").on(table.workspaceID, table.email)],
+ (table) => [
+ ...workspaceIndexes(table),
+ uniqueIndex("user_account_id").on(table.workspaceID, table.accountID),
+ uniqueIndex("user_email").on(table.workspaceID, table.email),
+ ],
)
diff --git a/packages/console/core/src/user.ts b/packages/console/core/src/user.ts
index 7914926ff..ecf592297 100644
--- a/packages/console/core/src/user.ts
+++ b/packages/console/core/src/user.ts
@@ -1,18 +1,180 @@
import { z } from "zod"
-import { eq } from "drizzle-orm"
+import { and, eq, isNull, sql } from "drizzle-orm"
import { fn } from "./util/fn"
import { Database } from "./drizzle"
-import { UserTable } from "./schema/user.sql"
+import { UserRole, UserTable } from "./schema/user.sql"
+import { Actor } from "./actor"
+import { Identifier } from "./identifier"
+import { render } from "@jsx-email/render"
+import { InviteEmail } from "@opencode/console-mail/InviteEmail.jsx"
+import { AWS } from "./aws"
+import { Account } from "./account"
export namespace User {
- export const fromID = fn(z.string(), async (id) =>
- Database.transaction(async (tx) => {
- return tx
+ const assertAdmin = async () => {
+ const actor = Actor.assert("user")
+ const user = await User.fromID(actor.properties.userID)
+ if (user?.role !== "admin") {
+ throw new Error(`Expected admin user, got ${user?.role}`)
+ }
+ }
+
+ const assertNotSelf = (id: string) => {
+ const actor = Actor.assert("user")
+ if (actor.properties.userID === id) {
+ throw new Error(`Expected not self actor, got self actor`)
+ }
+ }
+
+ export const list = fn(z.void(), () =>
+ Database.use((tx) =>
+ tx
.select()
.from(UserTable)
- .where(eq(UserTable.id, id))
- .execute()
- .then((rows) => rows[0])
+ .where(and(eq(UserTable.workspaceID, Actor.workspace()), isNull(UserTable.timeDeleted))),
+ ),
+ )
+
+ export const fromID = fn(z.string(), (id) =>
+ Database.use((tx) =>
+ tx
+ .select()
+ .from(UserTable)
+ .where(and(eq(UserTable.workspaceID, Actor.workspace()), eq(UserTable.id, id), isNull(UserTable.timeDeleted)))
+ .then((rows) => rows[0]),
+ ),
+ )
+
+ export const invite = fn(
+ z.object({
+ email: z.string(),
+ role: z.enum(UserRole),
+ }),
+ async ({ email, role }) => {
+ await assertAdmin()
+
+ const workspaceID = Actor.workspace()
+ await Database.transaction(async (tx) => {
+ const account = await Account.fromEmail(email)
+ const members = await tx.select().from(UserTable).where(eq(UserTable.workspaceID, Actor.workspace()))
+
+ await (async () => {
+ if (account) {
+ // case: account previously invited and removed
+ if (members.some((m) => m.oldAccountID === account.id)) {
+ await tx
+ .update(UserTable)
+ .set({
+ timeDeleted: null,
+ oldAccountID: null,
+ accountID: account.id,
+ })
+ .where(and(eq(UserTable.workspaceID, Actor.workspace()), eq(UserTable.accountID, account.id)))
+ return
+ }
+ // case: account previously not invited
+ await tx
+ .insert(UserTable)
+ .values({
+ id: Identifier.create("user"),
+ name: "",
+ accountID: account.id,
+ workspaceID,
+ role,
+ })
+ .catch((e: any) => {
+ if (e.message.match(/Duplicate entry '.*' for key 'user.user_account_id'/))
+ throw new Error("A user with this email has already been invited.")
+ throw e
+ })
+ return
+ }
+ // case: email previously invited and removed
+ if (members.some((m) => m.oldEmail === email)) {
+ await tx
+ .update(UserTable)
+ .set({
+ timeDeleted: null,
+ oldEmail: null,
+ email,
+ })
+ .where(and(eq(UserTable.workspaceID, Actor.workspace()), eq(UserTable.email, email)))
+ return
+ }
+ // case: email previously not invited
+ await tx
+ .insert(UserTable)
+ .values({
+ id: Identifier.create("user"),
+ name: "",
+ email,
+ workspaceID,
+ role,
+ })
+ .catch((e: any) => {
+ if (e.message.match(/Duplicate entry '.*' for key 'user.user_email'/))
+ throw new Error("A user with this email has already been invited.")
+ throw e
+ })
+ })()
+ })
+
+ // send email, ignore errors
+ try {
+ 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,
+ }),
+ ),
+ })
+ } catch (e) {
+ console.error(e)
+ }
+ },
+ )
+
+ export const updateRole = fn(
+ z.object({
+ id: z.string(),
+ role: z.enum(UserRole),
}),
+ async ({ id, role }) => {
+ await assertAdmin()
+ if (role === "member") assertNotSelf(id)
+ return await Database.use((tx) =>
+ tx
+ .update(UserTable)
+ .set({ role })
+ .where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, Actor.workspace()))),
+ )
+ },
)
+
+ export const remove = fn(z.string(), async (id) => {
+ await assertAdmin()
+ assertNotSelf(id)
+
+ return await Database.use(async (tx) => {
+ const email = await tx
+ .select({ email: UserTable.email })
+ .from(UserTable)
+ .where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, Actor.workspace())))
+ .then((rows) => rows[0]?.email)
+ if (!email) throw new 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, Actor.workspace())))
+ })
+ })
}
diff --git a/packages/console/core/src/workspace.ts b/packages/console/core/src/workspace.ts
index d6eeb80cf..e6356e49d 100644
--- a/packages/console/core/src/workspace.ts
+++ b/packages/console/core/src/workspace.ts
@@ -19,6 +19,7 @@ export namespace Workspace {
await tx.insert(UserTable).values({
workspaceID,
id: Identifier.create("user"),
+ accountID: account.properties.accountID,
email: account.properties.email,
name: "",
role: "admin",