From 70da3a9399d3385b53f7831beb08f716b972860d Mon Sep 17 00:00:00 2001 From: Frank Date: Wed, 1 Oct 2025 19:34:37 -0400 Subject: wip: zen --- packages/console/app/package.json | 2 + packages/console/app/public/email | 1 + packages/console/app/src/context/auth.ts | 16 +- packages/console/app/src/routes/workspace/[id].tsx | 41 +- .../app/src/routes/workspace/member-section.tsx | 281 +++++++-- .../console/core/migrations/0021_flawless_clea.sql | 2 + .../core/migrations/meta/0021_snapshot.json | 702 +++++++++++++++++++++ .../console/core/migrations/meta/_journal.json | 7 + packages/console/core/package.json | 1 + packages/console/core/src/account.ts | 8 +- packages/console/core/src/actor.ts | 2 - packages/console/core/src/aws.ts | 63 ++ packages/console/core/src/billing.ts | 2 +- packages/console/core/src/schema/user.sql.ts | 3 +- packages/console/core/src/workspace.ts | 1 - packages/console/function/src/auth.ts | 6 +- packages/console/function/sst-env.d.ts | 8 + packages/console/mail/emails/components.tsx | 108 ++++ packages/console/mail/emails/styles.ts | 110 ++++ .../console/mail/emails/templates/InviteEmail.tsx | 113 ++++ .../templates/static/ibm-plex-mono-latin-400.woff2 | Bin 0 -> 10088 bytes .../templates/static/ibm-plex-mono-latin-500.woff2 | Bin 0 -> 10132 bytes .../templates/static/ibm-plex-mono-latin-600.woff2 | Bin 0 -> 10148 bytes .../templates/static/ibm-plex-mono-latin-700.woff2 | Bin 0 -> 10140 bytes .../mail/emails/templates/static/rubik-latin.woff2 | Bin 0 -> 35320 bytes .../mail/emails/templates/static/zen-logo.png | Bin 0 -> 8336 bytes packages/console/mail/package.json | 19 + packages/console/mail/sst-env.d.ts | 9 + packages/console/resource/sst-env.d.ts | 8 + 29 files changed, 1419 insertions(+), 94 deletions(-) create mode 120000 packages/console/app/public/email create mode 100644 packages/console/core/migrations/0021_flawless_clea.sql create mode 100644 packages/console/core/migrations/meta/0021_snapshot.json create mode 100644 packages/console/core/src/aws.ts create mode 100644 packages/console/mail/emails/components.tsx create mode 100644 packages/console/mail/emails/styles.ts create mode 100644 packages/console/mail/emails/templates/InviteEmail.tsx create mode 100644 packages/console/mail/emails/templates/static/ibm-plex-mono-latin-400.woff2 create mode 100644 packages/console/mail/emails/templates/static/ibm-plex-mono-latin-500.woff2 create mode 100644 packages/console/mail/emails/templates/static/ibm-plex-mono-latin-600.woff2 create mode 100644 packages/console/mail/emails/templates/static/ibm-plex-mono-latin-700.woff2 create mode 100644 packages/console/mail/emails/templates/static/rubik-latin.woff2 create mode 100644 packages/console/mail/emails/templates/static/zen-logo.png create mode 100644 packages/console/mail/package.json create mode 100644 packages/console/mail/sst-env.d.ts (limited to 'packages/console') diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 5c43c3bbe..18a2f5e23 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -11,8 +11,10 @@ }, "dependencies": { "@ibm/plex": "6.4.1", + "@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/public/email b/packages/console/app/public/email new file mode 120000 index 000000000..0df016d01 --- /dev/null +++ b/packages/console/app/public/email @@ -0,0 +1 @@ +../../mail/emails/templates/static \ No newline at end of file diff --git a/packages/console/app/src/context/auth.ts b/packages/console/app/src/context/auth.ts index 585cac3c6..7097787fb 100644 --- a/packages/console/app/src/context/auth.ts +++ b/packages/console/app/src/context/auth.ts @@ -1,5 +1,5 @@ import { getRequestEvent } from "solid-js/web" -import { and, Database, eq, inArray } from "@opencode/console-core/drizzle/index.js" +import { and, Database, eq, inArray, sql } from "@opencode/console-core/drizzle/index.js" import { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js" import { UserTable } from "@opencode/console-core/schema/user.sql.js" import { redirect } from "@solidjs/router" @@ -54,8 +54,8 @@ export const getActor = async (workspace?: string): Promise => { } const accounts = Object.keys(auth.data.account ?? {}) if (accounts.length) { - const result = await Database.transaction(async (tx) => { - return await tx + const result = await Database.use((tx) => + tx .select({ user: UserTable, }) @@ -65,9 +65,15 @@ export const getActor = async (workspace?: string): Promise => { .where(and(inArray(AccountTable.id, accounts), eq(WorkspaceTable.id, workspace))) .limit(1) .execute() - .then((x) => x[0]) - }) + .then((x) => x[0]), + ) if (result) { + await Database.use((tx) => + tx + .update(UserTable) + .set({ timeSeen: sql`now()` }) + .where(eq(UserTable.id, result.user.id)), + ) return { type: "user", properties: { diff --git a/packages/console/app/src/routes/workspace/[id].tsx b/packages/console/app/src/routes/workspace/[id].tsx index 5257a8e97..df05b14b1 100644 --- a/packages/console/app/src/routes/workspace/[id].tsx +++ b/packages/console/app/src/routes/workspace/[id].tsx @@ -7,10 +7,33 @@ import { UsageSection } from "./usage-section" import { KeySection } from "./key-section" import { MemberSection } from "./member-section" import { Show } from "solid-js" -import { useParams } from "@solidjs/router" +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" + +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 } + }, workspaceID) +}, "user.get") export default function () { const params = useParams() + const data = createAsync(() => getUser(params.id)) return (
@@ -27,13 +50,17 @@ export default function () {
- - + + + + + + - - - + + +
) @@ -43,6 +70,6 @@ export function isBeta(workspaceID: string) { return [ "wrk_01K46JDFR0E75SG2Q8K172KF3Y", // production "wrk_01K4NFRR5P7FSYWH88307B4DDS", // dev - "wrk_01K68M8J1KK0PJ39H59B1EGHP6", // frank + "wrk_01K6G7HBZ7C046A4XK01CVD0NS", // frank ].includes(workspaceID) } diff --git a/packages/console/app/src/routes/workspace/member-section.tsx b/packages/console/app/src/routes/workspace/member-section.tsx index d0afd7eb9..0e3a101fd 100644 --- a/packages/console/app/src/routes/workspace/member-section.tsx +++ b/packages/console/app/src/routes/workspace/member-section.tsx @@ -2,11 +2,99 @@ import { json, query, action, useParams, createAsync, useSubmission } from "@sol import { createEffect, createSignal, For, Show } from "solid-js" import { withActor } from "~/context/auth.withActor" import { createStore } from "solid-js/store" -import { formatDateUTC, formatDateForTable } from "./common" import styles from "./member-section.module.css" -import { and, Database, eq, sql } from "@opencode/console-core/drizzle/index.js" +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 { 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 +} + +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, + currentUserID: actor.properties.userID, + })) + }, workspaceID) +}, "member.list") + +const inviteMember = action(async (form: FormData) => { + "use server" + const email = form.get("email")?.toString().trim() + if (!email) return { error: "Email is required" } + const workspaceID = form.get("workspaceID")?.toString() + if (!workspaceID) return { error: "Workspace ID is required" } + 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, + }) + .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), + { revalidate: listMembers.key }, + ) +}, "member.create") const removeMember = action(async (form: FormData) => { "use server" @@ -15,57 +103,57 @@ const removeMember = action(async (form: FormData) => { const workspaceID = form.get("workspaceID")?.toString() if (!workspaceID) return { error: "Workspace ID is required" } return json( - await withActor( - () => - Database.use((tx) => - tx - .update(UserTable) - .set({ timeDeleted: sql`now()` }) - .where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, workspaceID))), - ), - workspaceID, - ), + 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), { revalidate: listMembers.key }, ) }, "member.remove") -const inviteMember = action(async (form: FormData) => { +const updateMemberRole = action(async (form: FormData) => { "use server" - const email = form.get("email")?.toString().trim() - if (!email) return { error: "Email is required" } + const id = form.get("id")?.toString() + if (!id) return { error: "ID is required" } const workspaceID = form.get("workspaceID")?.toString() if (!workspaceID) return { error: "Workspace ID is required" } const role = form.get("role")?.toString() as (typeof UserRole)[number] if (!role) return { error: "Role is required" } return json( - await withActor( - () => - Database.use((tx) => - tx - .insert(UserTable) - .values({ - id: Identifier.create("user"), - name: "", - email, - workspaceID, - role, - }) - .then((data) => ({ error: undefined, data })) - .catch((e) => ({ error: e.message as string })), - ), - workspaceID, - ), + 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))) + .then((data) => ({ error: undefined, data })) + .catch((e) => ({ error: e.message as string })), + ) + }, workspaceID), { revalidate: listMembers.key }, ) -}, "member.create") - -const listMembers = query(async (workspaceID: string) => { - "use server" - return withActor( - () => Database.use((tx) => tx.select().from(UserTable).where(eq(UserTable.workspaceID, workspaceID))), - workspaceID, - ) -}, "member.list") +}, "member.updateRole") export function MemberCreateForm() { const params = useParams() @@ -144,9 +232,89 @@ export function MemberCreateForm() { ) } +function MemberRow(props: { member: any; workspaceID: string; currentUserID: string | null }) { + const [editing, setEditing] = createSignal(false) + const submission = useSubmission(updateMemberRole) + const isCurrentUser = () => props.currentUserID === props.member.id + + createEffect(() => { + if (!submission.pending && submission.result && !submission.result.error) { + setEditing(false) + } + }) + + return ( + + {props.member.email} + {props.member.role} + }> + invited + + + + +
+ + + +
+
+ + + } + > + + +
+
{props.member.email}
+ + + Role: {props.member.role}}> +
+ + +
+
+ + {(err) =>
{err()}
} +
+
+ + + + +
+
+ + +
+ ) +} + export function MemberSection() { const params = useParams() - const members = createAsync(() => listMembers(params.id)) + const data = createAsync(() => listMembers(params.id)) return (
@@ -157,7 +325,7 @@ export function MemberSection() {

Invite a member to your workspace

@@ -169,32 +337,15 @@ export function MemberSection() { Email Role - Joined + - - {(member) => { - return ( - - {member.email} - {member.role} - invited}> - - {formatDateForTable(member.timeSeen!)} - - - -
- - - -
- - - ) - }} + + {(member) => ( + + )} diff --git a/packages/console/core/migrations/0021_flawless_clea.sql b/packages/console/core/migrations/0021_flawless_clea.sql new file mode 100644 index 000000000..8c4489c2f --- /dev/null +++ b/packages/console/core/migrations/0021_flawless_clea.sql @@ -0,0 +1,2 @@ +ALTER TABLE `user` MODIFY COLUMN `email` varchar(255);--> statement-breakpoint +ALTER TABLE `user` ADD `old_email` varchar(255); \ No newline at end of file diff --git a/packages/console/core/migrations/meta/0021_snapshot.json b/packages/console/core/migrations/meta/0021_snapshot.json new file mode 100644 index 000000000..b285e34fa --- /dev/null +++ b/packages/console/core/migrations/meta/0021_snapshot.json @@ -0,0 +1,702 @@ +{ + "version": "5", + "dialect": "mysql", + "id": "14616ba2-c21e-4787-a289-f2a3eb6de04f", + "prevId": "908437f9-54ed-4c83-b555-614926e326f8", + "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 + }, + "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_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 5b45082fd..6879a3b3f 100644 --- a/packages/console/core/migrations/meta/_journal.json +++ b/packages/console/core/migrations/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1759169697658, "tag": "0020_supreme_jack_power", "breakpoints": true + }, + { + "idx": 21, + "version": "5", + "when": 1759186023755, + "tag": "0021_flawless_clea", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/console/core/package.json b/packages/console/core/package.json index a1d0681cf..acbef22ee 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -8,6 +8,7 @@ "@aws-sdk/client-sts": "3.782.0", "@opencode/console-resource": "workspace:*", "@planetscale/database": "1.19.0", + "aws4fetch": "1.0.20", "drizzle-orm": "0.41.0", "postgres": "3.4.7", "stripe": "18.0.0", diff --git a/packages/console/core/src/account.ts b/packages/console/core/src/account.ts index cb123e048..3bed2bef1 100644 --- a/packages/console/core/src/account.ts +++ b/packages/console/core/src/account.ts @@ -54,13 +54,7 @@ export namespace Account { .select(getTableColumns(WorkspaceTable)) .from(WorkspaceTable) .innerJoin(UserTable, eq(UserTable.workspaceID, WorkspaceTable.id)) - .where( - and( - eq(UserTable.email, actor.properties.email), - isNull(UserTable.timeDeleted), - isNull(WorkspaceTable.timeDeleted), - ), - ) + .where(and(eq(UserTable.email, actor.properties.email), isNull(WorkspaceTable.timeDeleted))) .execute(), ) } diff --git a/packages/console/core/src/actor.ts b/packages/console/core/src/actor.ts index f9db01293..0d13f7216 100644 --- a/packages/console/core/src/actor.ts +++ b/packages/console/core/src/actor.ts @@ -1,5 +1,4 @@ import { Context } from "./context" -import { UserRole } from "./schema/user.sql" import { Log } from "./util/log" export namespace Actor { @@ -21,7 +20,6 @@ export namespace Actor { properties: { userID: string workspaceID: string - role: (typeof UserRole)[number] } } diff --git a/packages/console/core/src/aws.ts b/packages/console/core/src/aws.ts new file mode 100644 index 000000000..200e29e4a --- /dev/null +++ b/packages/console/core/src/aws.ts @@ -0,0 +1,63 @@ +import { z } from "zod" +import { Resource } from "@opencode/console-resource" +import { AwsClient } from "aws4fetch" +import { fn } from "./util/fn" + +export namespace AWS { + let client: AwsClient + + const createClient = () => { + if (!client) { + client = new AwsClient({ + accessKeyId: Resource.AWS_SES_ACCESS_KEY_ID.value, + secretAccessKey: Resource.AWS_SES_SECRET_ACCESS_KEY.value, + region: "us-east-1", + }) + } + return client + } + + export const sendEmail = fn( + z.object({ + to: z.string(), + subject: z.string(), + body: z.string(), + }), + async (input) => { + const res = await createClient().fetch("https://email.us-east-1.amazonaws.com/v2/email/outbound-emails", { + method: "POST", + headers: { + "X-Amz-Target": "SES.SendEmail", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + FromEmailAddress: `OpenCode Zen `, + Destination: { + ToAddresses: [input.to], + }, + Content: { + Simple: { + Subject: { + Charset: "UTF-8", + Data: input.subject, + }, + Body: { + Text: { + Charset: "UTF-8", + Data: input.body, + }, + Html: { + Charset: "UTF-8", + Data: input.body, + }, + }, + }, + }, + }), + }) + if (!res.ok) { + throw new Error(`Failed to send email: ${res.statusText}`) + } + }, + ) +} diff --git a/packages/console/core/src/billing.ts b/packages/console/core/src/billing.ts index a87498a33..9c683a359 100644 --- a/packages/console/core/src/billing.ts +++ b/packages/console/core/src/billing.ts @@ -206,7 +206,7 @@ export namespace Billing { }, } : { - customer_email: user.email, + customer_email: user.email!, customer_creation: "always", }), currency: "usd", diff --git a/packages/console/core/src/schema/user.sql.ts b/packages/console/core/src/schema/user.sql.ts index 34939474e..eaadb06d5 100644 --- a/packages/console/core/src/schema/user.sql.ts +++ b/packages/console/core/src/schema/user.sql.ts @@ -9,7 +9,8 @@ export const UserTable = mysqlTable( { ...workspaceColumns, ...timestamps, - email: varchar("email", { length: 255 }).notNull(), + email: varchar("email", { length: 255 }), + oldEmail: varchar("old_email", { length: 255 }), name: varchar("name", { length: 255 }).notNull(), timeSeen: utc("time_seen"), color: int("color"), diff --git a/packages/console/core/src/workspace.ts b/packages/console/core/src/workspace.ts index 0ff3a1532..d6eeb80cf 100644 --- a/packages/console/core/src/workspace.ts +++ b/packages/console/core/src/workspace.ts @@ -21,7 +21,6 @@ export namespace Workspace { id: Identifier.create("user"), email: account.properties.email, name: "", - timeSeen: sql`now()`, role: "admin", }) await tx.insert(BillingTable).values({ diff --git a/packages/console/function/src/auth.ts b/packages/console/function/src/auth.ts index 5dc799683..77199fef5 100644 --- a/packages/console/function/src/auth.ts +++ b/packages/console/function/src/auth.ts @@ -111,11 +111,7 @@ export default { } else if (response.provider === "google") { if (!response.id.email_verified) throw new Error("Google email not verified") email = response.id.email as string - } - //if (response.provider === "email") { - // email = response.claims.email - //} - else throw new Error("Unsupported provider") + } else throw new Error("Unsupported provider") if (!email) throw new Error("No email found") diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index 0cd862dff..6a5d2bbf4 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -10,6 +10,14 @@ declare module "sst" { "type": "sst.sst.Linkable" "value": string } + "AWS_SES_ACCESS_KEY_ID": { + "type": "sst.sst.Secret" + "value": string + } + "AWS_SES_SECRET_ACCESS_KEY": { + "type": "sst.sst.Secret" + "value": string + } "Console": { "type": "sst.cloudflare.SolidStart" "url": string diff --git a/packages/console/mail/emails/components.tsx b/packages/console/mail/emails/components.tsx new file mode 100644 index 000000000..d030b6cbf --- /dev/null +++ b/packages/console/mail/emails/components.tsx @@ -0,0 +1,108 @@ +// @ts-nocheck +import React from "react" +import { Font, Hr as JEHr, Text as JEText, type HrProps, type TextProps } from "@jsx-email/all" +import { DIVIDER_COLOR, SURFACE_DIVIDER_COLOR, textColor } from "./styles" + +export function Text(props: TextProps) { + return +} + +export function Hr(props: HrProps) { + return +} + +export function SurfaceHr(props: HrProps) { + return ( + + ) +} + +export function Title({ children }: TitleProps) { + return React.createElement("title", null, children) +} + +export function A({ children, ...props }: AProps) { + return React.createElement("a", props, children) +} + +export function Span({ children, ...props }: SpanProps) { + return React.createElement("span", props, children) +} + +export function Wbr({ children, ...props }: WbrProps) { + return React.createElement("wbr", props, children) +} + +export function Fonts({ assetsUrl }: { assetsUrl: string }) { + return ( + <> + + + + + + + ) +} + +export function SplitString({ text, split }: { text: string; split: number }) { + const segments: JSX.Element[] = [] + for (let i = 0; i < text.length; i += split) { + segments.push(<>{text.slice(i, i + split)}) + if (i + split < text.length) { + segments.push() + } + } + return <>{segments} +} diff --git a/packages/console/mail/emails/styles.ts b/packages/console/mail/emails/styles.ts new file mode 100644 index 000000000..f9b62a7cd --- /dev/null +++ b/packages/console/mail/emails/styles.ts @@ -0,0 +1,110 @@ +export const unit = 16; + +export const GREY_COLOR = [ + "#1A1A2E", //0 + "#2F2F41", //1 + "#444454", //2 + "#585867", //3 + "#6D6D7A", //4 + "#82828D", //5 + "#9797A0", //6 + "#ACACB3", //7 + "#C1C1C6", //8 + "#D5D5D9", //9 + "#EAEAEC", //10 + "#FFFFFF", //11 +]; + +export const BLUE_COLOR = "#395C6B"; +export const DANGER_COLOR = "#ED322C"; +export const TEXT_COLOR = GREY_COLOR[0]; +export const SECONDARY_COLOR = GREY_COLOR[5]; +export const DIMMED_COLOR = GREY_COLOR[7]; +export const DIVIDER_COLOR = GREY_COLOR[10]; +export const BACKGROUND_COLOR = "#F0F0F1"; +export const SURFACE_COLOR = DIVIDER_COLOR; +export const SURFACE_DIVIDER_COLOR = GREY_COLOR[9]; + +export const body = { + background: BACKGROUND_COLOR, +}; + +export const container = { + minWidth: "600px", +}; + +export const medium = { + fontWeight: 500, +}; + +export const danger = { + color: DANGER_COLOR, +}; + +export const frame = { + padding: `${unit * 1.5}px`, + border: `1px solid ${SURFACE_DIVIDER_COLOR}`, + background: "#FFF", + borderRadius: "6px", + boxShadow: `0 1px 2px rgba(0,0,0,0.03), + 0 2px 4px rgba(0,0,0,0.03), + 0 2px 6px rgba(0,0,0,0.03)`, +}; + +export const textColor = { + color: TEXT_COLOR, +}; + +export const code = { + fontFamily: "IBM Plex Mono, monospace", +}; + +export const headingHr = { + margin: `${unit}px 0`, +}; + +export const buttonPrimary = { + ...code, + padding: "12px 18px", + color: "#FFF", + borderRadius: "4px", + background: BLUE_COLOR, + fontSize: "12px", + fontWeight: 500, +}; + +export const compactText = { + margin: "0 0 2px", +}; + +export const breadcrumb = { + fontSize: "14px", + color: SECONDARY_COLOR, +}; + +export const breadcrumbColonSeparator = { + padding: " 0 4px", + color: DIMMED_COLOR, +}; + +export const breadcrumbSeparator = { + color: DIVIDER_COLOR, +}; + +export const heading = { + fontSize: "22px", + fontWeight: 500, +}; + +export const sectionLabel = { + ...code, + ...compactText, + letterSpacing: "0.5px", + fontSize: "13px", + fontWeight: 500, + color: DIMMED_COLOR, +}; + +export const footerLink = { + fontSize: "14px", +}; diff --git a/packages/console/mail/emails/templates/InviteEmail.tsx b/packages/console/mail/emails/templates/InviteEmail.tsx new file mode 100644 index 000000000..978080a9c --- /dev/null +++ b/packages/console/mail/emails/templates/InviteEmail.tsx @@ -0,0 +1,113 @@ +// @ts-nocheck +import React from "react" +import { Img, Row, Html, Link, Body, Head, Button, Column, Preview, Section, Container } from "@jsx-email/all" +import { Hr, Text, Fonts, SplitString, Title, A, Span } from "../components" +import { + unit, + body, + code, + frame, + medium, + heading, + container, + headingHr, + footerLink, + breadcrumb, + compactText, + buttonPrimary, + breadcrumbColonSeparator, +} from "../styles" + +const LOCAL_ASSETS_URL = "/static" +const CONSOLE_URL = "https://opencode.ai/" +const DOC_URL = "https://opencode.ai/docs/zen" + +interface InviteEmailProps { + workspace: string + assetsUrl: string +} +export const InviteEmail = ({ workspace, assetsUrl = LOCAL_ASSETS_URL }: InviteEmailProps) => { + const subject = `Join the ${workspace} workspace` + const messagePlain = `You've been invited to join the ${workspace} workspace in the OpenCode Zen Console.` + const url = `${CONSOLE_URL}workspace/${workspace}` + return ( + + + {`OpenCode Zen — ${messagePlain}`} + + + {messagePlain} + + +
+ + + + OpenCode Zen Logo + + + + + + + + + +
+
+
+ +
+ + OpenCode Zen + : + {workspace} + + + + + + +
+
+ + You've been invited to join the{" "} + + {workspace} + {" "} + workspace in the{" "} + + OpenCode Zen Console + + . + +
+ + + +
+
+
+ + + + + Console + + + + + About + + + +
+
+ + + ) +} + +export default InviteEmail diff --git a/packages/console/mail/emails/templates/static/ibm-plex-mono-latin-400.woff2 b/packages/console/mail/emails/templates/static/ibm-plex-mono-latin-400.woff2 new file mode 100644 index 000000000..89713fa9a Binary files /dev/null and b/packages/console/mail/emails/templates/static/ibm-plex-mono-latin-400.woff2 differ diff --git a/packages/console/mail/emails/templates/static/ibm-plex-mono-latin-500.woff2 b/packages/console/mail/emails/templates/static/ibm-plex-mono-latin-500.woff2 new file mode 100644 index 000000000..21413e73f Binary files /dev/null and b/packages/console/mail/emails/templates/static/ibm-plex-mono-latin-500.woff2 differ diff --git a/packages/console/mail/emails/templates/static/ibm-plex-mono-latin-600.woff2 b/packages/console/mail/emails/templates/static/ibm-plex-mono-latin-600.woff2 new file mode 100644 index 000000000..4a2ebd962 Binary files /dev/null and b/packages/console/mail/emails/templates/static/ibm-plex-mono-latin-600.woff2 differ diff --git a/packages/console/mail/emails/templates/static/ibm-plex-mono-latin-700.woff2 b/packages/console/mail/emails/templates/static/ibm-plex-mono-latin-700.woff2 new file mode 100644 index 000000000..624783be5 Binary files /dev/null and b/packages/console/mail/emails/templates/static/ibm-plex-mono-latin-700.woff2 differ diff --git a/packages/console/mail/emails/templates/static/rubik-latin.woff2 b/packages/console/mail/emails/templates/static/rubik-latin.woff2 new file mode 100644 index 000000000..c533eec64 Binary files /dev/null and b/packages/console/mail/emails/templates/static/rubik-latin.woff2 differ diff --git a/packages/console/mail/emails/templates/static/zen-logo.png b/packages/console/mail/emails/templates/static/zen-logo.png new file mode 100644 index 000000000..382223627 Binary files /dev/null and b/packages/console/mail/emails/templates/static/zen-logo.png differ diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json new file mode 100644 index 000000000..8ceb170e6 --- /dev/null +++ b/packages/console/mail/package.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json.schemastore.org/package.json", + "name": "@opencode/console-mail", + "version": "0.13.5", + "private": true, + "type": "module", + "dependencies": { + "@jsx-email/all": "2.2.3", + "@jsx-email/cli": "1.4.3", + "@types/react": "18.0.25", + "react": "18.2.0" + }, + "exports": { + "./*": "./emails/templates/*" + }, + "scripts": { + "dev": "email preview emails/templates" + } +} diff --git a/packages/console/mail/sst-env.d.ts b/packages/console/mail/sst-env.d.ts new file mode 100644 index 000000000..9b9de7327 --- /dev/null +++ b/packages/console/mail/sst-env.d.ts @@ -0,0 +1,9 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index 0cd862dff..6a5d2bbf4 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -10,6 +10,14 @@ declare module "sst" { "type": "sst.sst.Linkable" "value": string } + "AWS_SES_ACCESS_KEY_ID": { + "type": "sst.sst.Secret" + "value": string + } + "AWS_SES_SECRET_ACCESS_KEY": { + "type": "sst.sst.Secret" + "value": string + } "Console": { "type": "sst.cloudflare.SolidStart" "url": string -- cgit v1.2.3