diff options
| author | Frank <[email protected]> | 2025-10-02 13:58:38 -0400 |
|---|---|---|
| committer | Frank <[email protected]> | 2025-10-02 13:58:40 -0400 |
| commit | a45fa7a93c7864ef7eed792949755b334a9b2524 (patch) | |
| tree | 00fedb5631806de856ecf9e5b49c997521d9d31d /packages/console/core/src | |
| parent | ae15c914556a51c042f37284d52945d6c480b37f (diff) | |
| download | opencode-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.ts | 10 | ||||
| -rw-r--r-- | packages/console/core/src/user.ts | 178 | ||||
| -rw-r--r-- | packages/console/core/src/workspace.ts | 1 |
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", |
