From a45fa7a93c7864ef7eed792949755b334a9b2524 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 2 Oct 2025 13:58:38 -0400 Subject: [PATCH] wip: zen --- bun.lock | 4 +- packages/console/app/package.json | 2 - packages/console/app/src/context/auth.ts | 1 - .../console/app/src/routes/workspace/[id].tsx | 18 +- .../src/routes/workspace/member-section.tsx | 127 +-- .../migrations/0022_nice_dreadnoughts.sql | 3 + .../core/migrations/meta/0022_snapshot.json | 724 ++++++++++++++++++ .../core/migrations/meta/_journal.json | 7 + packages/console/core/package.json | 2 + packages/console/core/src/schema/user.sql.ts | 10 +- packages/console/core/src/user.ts | 180 ++++- packages/console/core/src/workspace.ts | 1 + packages/console/core/tsconfig.json | 2 + 13 files changed, 948 insertions(+), 133 deletions(-) create mode 100644 packages/console/core/migrations/0022_nice_dreadnoughts.sql create mode 100644 packages/console/core/migrations/meta/0022_snapshot.json diff --git a/bun.lock b/bun.lock index 4439ddfb..f279671c 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 f83ddd26..5b25a5f7 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 7097787f..079f05c9 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 => { 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 df05b14b..ad1f47bd 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 0e3a101f..7dc89334 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 00000000..60c7f869 --- /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 00000000..9486ee34 --- /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 6879a3b3..a240ce4a 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 333a337e..4ae04b9a 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 eaadb06d..e1da69ee 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 7914926f..ecf59229 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 d6eeb80c..e6356e49 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 0faf16aa..3218dd7e 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"] } }