Frank 6 месяцев назад
Родитель
Сommit
a45fa7a93c

+ 2 - 2
bun.lock

@@ -48,11 +48,9 @@
       "name": "@opencode/console-app",
       "dependencies": {
         "@ibm/plex": "6.4.1",
-        "@jsx-email/render": "1.1.1",
         "@kobalte/core": "catalog:",
         "@openauthjs/openauth": "0.0.0-20250322224806",
         "@opencode/console-core": "workspace:*",
-        "@opencode/console-mail": "workspace:*",
         "@solidjs/meta": "^0.29.4",
         "@solidjs/router": "^0.15.0",
         "@solidjs/start": "^1.1.0",
@@ -66,6 +64,8 @@
       "version": "0.14.0",
       "dependencies": {
         "@aws-sdk/client-sts": "3.782.0",
+        "@jsx-email/render": "1.1.1",
+        "@opencode/console-mail": "workspace:*",
         "@opencode/console-resource": "workspace:*",
         "@planetscale/database": "1.19.0",
         "aws4fetch": "1.0.20",

+ 0 - 2
packages/console/app/package.json

@@ -12,10 +12,8 @@
   "dependencies": {
     "@ibm/plex": "6.4.1",
     "@kobalte/core": "catalog:",
-    "@jsx-email/render": "1.1.1",
     "@openauthjs/openauth": "0.0.0-20250322224806",
     "@opencode/console-core": "workspace:*",
-    "@opencode/console-mail": "workspace:*",
     "@solidjs/meta": "^0.29.4",
     "@solidjs/router": "^0.15.0",
     "@solidjs/start": "^1.1.0",

+ 0 - 1
packages/console/app/src/context/auth.ts

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

+ 4 - 14
packages/console/app/src/routes/workspace/[id].tsx

@@ -10,24 +10,14 @@ import { Show } from "solid-js"
 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"
+import { User } from "@opencode/console-core/user.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 }
+    const actor = Actor.assert("user")
+    const user = await User.fromID(actor.properties.userID)
+    return { isAdmin: user?.role === "admin" }
   }, workspaceID)
 }, "user.get")
 

+ 24 - 103
packages/console/app/src/routes/workspace/member-section.tsx

@@ -3,46 +3,18 @@ import { createEffect, createSignal, For, Show } from "solid-js"
 import { withActor } from "~/context/auth.withActor"
 import { createStore } from "solid-js/store"
 import styles from "./member-section.module.css"
-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 { UserRole } from "@opencode/console-core/schema/user.sql.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
-}
+import { User } from "@opencode/console-core/user.js"
 
 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,
+    const actor = Actor.assert("user")
+    return {
+      members: await User.list(),
       currentUserID: actor.properties.userID,
-    }))
+    }
   }, workspaceID)
 }, "member.list")
 
@@ -55,43 +27,13 @@ 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(async () => {
-      await assertAdmin(workspaceID)
-      return Database.use((tx) =>
-        tx
-          .insert(UserTable)
-          .values({
-            id: Identifier.create("user"),
-            name: "",
-            email,
-            workspaceID,
-            role,
-          })
+    await withActor(
+      () =>
+        User.invite({ email, 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),
+          .catch((e) => ({ error: e.message as string })),
+      workspaceID,
+    ),
     { revalidate: listMembers.key },
   )
 }, "member.create")
@@ -103,29 +45,13 @@ const removeMember = action(async (form: FormData) => {
   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),
+    await withActor(
+      () =>
+        User.remove(id)
+          .then((data) => ({ error: undefined, data }))
+          .catch((e) => ({ error: e.message as string })),
+      workspaceID,
+    ),
     { revalidate: listMembers.key },
   )
 }, "member.remove")
@@ -139,18 +65,13 @@ const updateMemberRole = 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(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)))
+    await withActor(
+      () =>
+        User.updateRole({ id, role })
           .then((data) => ({ error: undefined, data }))
           .catch((e) => ({ error: e.message as string })),
-      )
-    }, workspaceID),
+      workspaceID,
+    ),
     { revalidate: listMembers.key },
   )
 }, "member.updateRole")

+ 3 - 0
packages/console/core/migrations/0022_nice_dreadnoughts.sql

@@ -0,0 +1,3 @@
+ALTER TABLE `user` ADD `account_id` varchar(30);--> statement-breakpoint
+ALTER TABLE `user` ADD `old_account_id` varchar(30);--> statement-breakpoint
+ALTER TABLE `user` ADD CONSTRAINT `user_account_id` UNIQUE(`workspace_id`,`account_id`);

+ 724 - 0
packages/console/core/migrations/meta/0022_snapshot.json

@@ -0,0 +1,724 @@
+{
+  "version": "5",
+  "dialect": "mysql",
+  "id": "2296e9e4-bee6-485b-a146-6666ac8dc0d0",
+  "prevId": "14616ba2-c21e-4787-a289-f2a3eb6de04f",
+  "tables": {
+    "account": {
+      "name": "account",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "email": {
+          "name": "email",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "email": {
+          "name": "email",
+          "columns": [
+            "email"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {},
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "billing": {
+      "name": "billing",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "customer_id": {
+          "name": "customer_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "payment_method_id": {
+          "name": "payment_method_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "payment_method_last4": {
+          "name": "payment_method_last4",
+          "type": "varchar(4)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "balance": {
+          "name": "balance",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "monthly_limit": {
+          "name": "monthly_limit",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "monthly_usage": {
+          "name": "monthly_usage",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "time_monthly_usage_updated": {
+          "name": "time_monthly_usage_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "reload": {
+          "name": "reload",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "reload_error": {
+          "name": "reload_error",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "time_reload_error": {
+          "name": "time_reload_error",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "time_reload_locked_till": {
+          "name": "time_reload_locked_till",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "global_customer_id": {
+          "name": "global_customer_id",
+          "columns": [
+            "customer_id"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "billing_workspace_id_id_pk": {
+          "name": "billing_workspace_id_id_pk",
+          "columns": [
+            "workspace_id",
+            "id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "payment": {
+      "name": "payment",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "customer_id": {
+          "name": "customer_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "invoice_id": {
+          "name": "invoice_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "payment_id": {
+          "name": "payment_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "amount": {
+          "name": "amount",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_refunded": {
+          "name": "time_refunded",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "payment_workspace_id_id_pk": {
+          "name": "payment_workspace_id_id_pk",
+          "columns": [
+            "workspace_id",
+            "id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "usage": {
+      "name": "usage",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "reasoning_tokens": {
+          "name": "reasoning_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_5m_tokens": {
+          "name": "cache_write_5m_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_1h_tokens": {
+          "name": "cache_write_1h_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost": {
+          "name": "cost",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "usage_workspace_id_id_pk": {
+          "name": "usage_workspace_id_id_pk",
+          "columns": [
+            "workspace_id",
+            "id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "key": {
+      "name": "key",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "actor": {
+          "name": "actor",
+          "type": "json",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "old_name": {
+          "name": "old_name",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_used": {
+          "name": "time_used",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "global_key": {
+          "name": "global_key",
+          "columns": [
+            "key"
+          ],
+          "isUnique": true
+        },
+        "name": {
+          "name": "name",
+          "columns": [
+            "workspace_id",
+            "name"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "key_workspace_id_id_pk": {
+          "name": "key_workspace_id_id_pk",
+          "columns": [
+            "workspace_id",
+            "id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "user": {
+      "name": "user",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "account_id": {
+          "name": "account_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "old_account_id": {
+          "name": "old_account_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "email": {
+          "name": "email",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "old_email": {
+          "name": "old_email",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_seen": {
+          "name": "time_seen",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "color": {
+          "name": "color",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "role": {
+          "name": "role",
+          "type": "enum('admin','member')",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "user_account_id": {
+          "name": "user_account_id",
+          "columns": [
+            "workspace_id",
+            "account_id"
+          ],
+          "isUnique": true
+        },
+        "user_email": {
+          "name": "user_email",
+          "columns": [
+            "workspace_id",
+            "email"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "user_workspace_id_id_pk": {
+          "name": "user_workspace_id_id_pk",
+          "columns": [
+            "workspace_id",
+            "id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "workspace": {
+      "name": "workspace",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "slug": {
+          "name": "slug",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "slug": {
+          "name": "slug",
+          "columns": [
+            "slug"
+          ],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "workspace_id": {
+          "name": "workspace_id",
+          "columns": [
+            "id"
+          ]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    }
+  },
+  "views": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "tables": {},
+    "indexes": {}
+  }
+}

+ 7 - 0
packages/console/core/migrations/meta/_journal.json

@@ -155,6 +155,13 @@
       "when": 1759186023755,
       "tag": "0021_flawless_clea",
       "breakpoints": true
+    },
+    {
+      "idx": 22,
+      "version": "5",
+      "when": 1759427432588,
+      "tag": "0022_nice_dreadnoughts",
+      "breakpoints": true
     }
   ]
 }

+ 2 - 0
packages/console/core/package.json

@@ -6,6 +6,8 @@
   "type": "module",
   "dependencies": {
     "@aws-sdk/client-sts": "3.782.0",
+    "@jsx-email/render": "1.1.1",
+    "@opencode/console-mail": "workspace:*",
     "@opencode/console-resource": "workspace:*",
     "@planetscale/database": "1.19.0",
     "aws4fetch": "1.0.20",

+ 8 - 2
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),
+  ],
 )

+ 170 - 8
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())))
+    })
+  })
 }

+ 1 - 0
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",

+ 2 - 0
packages/console/core/tsconfig.json

@@ -4,6 +4,8 @@
   "compilerOptions": {
     "module": "ESNext",
     "moduleResolution": "bundler",
+    "jsx": "preserve",
+    "jsxImportSource": "react",
     "types": ["@cloudflare/workers-types", "node"]
   }
 }