summaryrefslogtreecommitdiffhomepage
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
parentae15c914556a51c042f37284d52945d6c480b37f (diff)
downloadopencode-a45fa7a93c7864ef7eed792949755b334a9b2524.tar.gz
opencode-a45fa7a93c7864ef7eed792949755b334a9b2524.zip
wip: zen
-rw-r--r--bun.lock4
-rw-r--r--packages/console/app/package.json2
-rw-r--r--packages/console/app/src/context/auth.ts1
-rw-r--r--packages/console/app/src/routes/workspace/[id].tsx18
-rw-r--r--packages/console/app/src/routes/workspace/member-section.tsx127
-rw-r--r--packages/console/core/migrations/0022_nice_dreadnoughts.sql3
-rw-r--r--packages/console/core/migrations/meta/0022_snapshot.json724
-rw-r--r--packages/console/core/migrations/meta/_journal.json7
-rw-r--r--packages/console/core/package.json2
-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
-rw-r--r--packages/console/core/tsconfig.json2
13 files changed, 947 insertions, 132 deletions
diff --git a/bun.lock b/bun.lock
index 4439ddfbf..f279671ca 100644
--- a/bun.lock
+++ b/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",
diff --git a/packages/console/app/package.json b/packages/console/app/package.json
index f83ddd267..5b25a5f7c 100644
--- a/packages/console/app/package.json
+++ b/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",
diff --git a/packages/console/app/src/context/auth.ts b/packages/console/app/src/context/auth.ts
index 7097787fb..079f05c9c 100644
--- a/packages/console/app/src/context/auth.ts
+++ b/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,
},
}
}
diff --git a/packages/console/app/src/routes/workspace/[id].tsx b/packages/console/app/src/routes/workspace/[id].tsx
index df05b14b1..ad1f47bd4 100644
--- a/packages/console/app/src/routes/workspace/[id].tsx
+++ b/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")
diff --git a/packages/console/app/src/routes/workspace/member-section.tsx b/packages/console/app/src/routes/workspace/member-section.tsx
index 0e3a101fd..7dc893346 100644
--- a/packages/console/app/src/routes/workspace/member-section.tsx
+++ b/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")
diff --git a/packages/console/core/migrations/0022_nice_dreadnoughts.sql b/packages/console/core/migrations/0022_nice_dreadnoughts.sql
new file mode 100644
index 000000000..60c7f8691
--- /dev/null
+++ b/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`); \ No newline at end of file
diff --git a/packages/console/core/migrations/meta/0022_snapshot.json b/packages/console/core/migrations/meta/0022_snapshot.json
new file mode 100644
index 000000000..9486ee345
--- /dev/null
+++ b/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": {}
+ }
+} \ No newline at end of file
diff --git a/packages/console/core/migrations/meta/_journal.json b/packages/console/core/migrations/meta/_journal.json
index 6879a3b3f..a240ce4a4 100644
--- a/packages/console/core/migrations/meta/_journal.json
+++ b/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
}
]
} \ No newline at end of file
diff --git a/packages/console/core/package.json b/packages/console/core/package.json
index 333a337ed..4ae04b9a6 100644
--- a/packages/console/core/package.json
+++ b/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",
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",
diff --git a/packages/console/core/tsconfig.json b/packages/console/core/tsconfig.json
index 0faf16aab..3218dd7e3 100644
--- a/packages/console/core/tsconfig.json
+++ b/packages/console/core/tsconfig.json
@@ -4,6 +4,8 @@
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
+ "jsx": "preserve",
+ "jsxImportSource": "react",
"types": ["@cloudflare/workers-types", "node"]
}
}