summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/console/app/package.json2
l---------packages/console/app/public/email1
-rw-r--r--packages/console/app/src/context/auth.ts16
-rw-r--r--packages/console/app/src/routes/workspace/[id].tsx41
-rw-r--r--packages/console/app/src/routes/workspace/member-section.tsx281
-rw-r--r--packages/console/core/migrations/0021_flawless_clea.sql2
-rw-r--r--packages/console/core/migrations/meta/0021_snapshot.json702
-rw-r--r--packages/console/core/migrations/meta/_journal.json7
-rw-r--r--packages/console/core/package.json1
-rw-r--r--packages/console/core/src/account.ts8
-rw-r--r--packages/console/core/src/actor.ts2
-rw-r--r--packages/console/core/src/aws.ts63
-rw-r--r--packages/console/core/src/billing.ts2
-rw-r--r--packages/console/core/src/schema/user.sql.ts3
-rw-r--r--packages/console/core/src/workspace.ts1
-rw-r--r--packages/console/function/src/auth.ts6
-rw-r--r--packages/console/function/sst-env.d.ts8
-rw-r--r--packages/console/mail/emails/components.tsx108
-rw-r--r--packages/console/mail/emails/styles.ts110
-rw-r--r--packages/console/mail/emails/templates/InviteEmail.tsx113
-rw-r--r--packages/console/mail/emails/templates/static/ibm-plex-mono-latin-400.woff2bin0 -> 10088 bytes
-rw-r--r--packages/console/mail/emails/templates/static/ibm-plex-mono-latin-500.woff2bin0 -> 10132 bytes
-rw-r--r--packages/console/mail/emails/templates/static/ibm-plex-mono-latin-600.woff2bin0 -> 10148 bytes
-rw-r--r--packages/console/mail/emails/templates/static/ibm-plex-mono-latin-700.woff2bin0 -> 10140 bytes
-rw-r--r--packages/console/mail/emails/templates/static/rubik-latin.woff2bin0 -> 35320 bytes
-rw-r--r--packages/console/mail/emails/templates/static/zen-logo.pngbin0 -> 8336 bytes
-rw-r--r--packages/console/mail/package.json19
-rw-r--r--packages/console/mail/sst-env.d.ts9
-rw-r--r--packages/console/resource/sst-env.d.ts8
-rw-r--r--packages/function/sst-env.d.ts8
30 files changed, 1427 insertions, 94 deletions
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<Actor.Info> => {
}
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<Actor.Info> => {
.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 (
<div data-page="workspace-[id]">
<section data-component="title-section">
@@ -27,13 +50,17 @@ export default function () {
<div data-slot="sections">
<NewUserSection />
<KeySection />
- <Show when={isBeta(params.id)}>
- <MemberSection />
+ <Show when={data()?.isAdmin}>
+ <Show when={isBeta(params.id)}>
+ <MemberSection />
+ </Show>
+ <BillingSection />
+ <MonthlyLimitSection />
</Show>
- <BillingSection />
- <MonthlyLimitSection />
<UsageSection />
- <PaymentSection />
+ <Show when={data()?.isAdmin}>
+ <PaymentSection />
+ </Show>
</div>
</div>
)
@@ -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 (
+ <Show
+ when={editing()}
+ fallback={
+ <tr>
+ <td data-slot="member-email">{props.member.email}</td>
+ <td data-slot="member-role">{props.member.role}</td>
+ <Show when={!props.member.timeSeen} fallback={<td data-slot="member-joined"></td>}>
+ <td data-slot="member-joined">invited</td>
+ </Show>
+ <td data-slot="member-actions">
+ <button data-color="ghost" onClick={() => setEditing(true)}>
+ Edit
+ </button>
+ <Show when={!isCurrentUser()}>
+ <form action={removeMember} method="post">
+ <input type="hidden" name="id" value={props.member.id} />
+ <input type="hidden" name="workspaceID" value={props.workspaceID} />
+ <button data-color="ghost">Delete</button>
+ </form>
+ </Show>
+ </td>
+ </tr>
+ }
+ >
+ <tr>
+ <td colspan="4">
+ <form action={updateMemberRole} method="post">
+ <div data-slot="edit-member-email">{props.member.email}</div>
+ <input type="hidden" name="id" value={props.member.id} />
+ <input type="hidden" name="workspaceID" value={props.workspaceID} />
+ <Show when={!isCurrentUser()} fallback={<div data-slot="current-user-role">Role: {props.member.role}</div>}>
+ <div data-slot="role-selector">
+ <label>
+ <input type="radio" name="role" value="admin" checked={props.member.role === "admin"} />
+ <div>
+ <strong>Admin</strong>
+ <p>Can manage models, members, and billing</p>
+ </div>
+ </label>
+ <label>
+ <input type="radio" name="role" value="member" checked={props.member.role === "member"} />
+ <div>
+ <strong>Member</strong>
+ <p>Can only generate API keys for themselves</p>
+ </div>
+ </label>
+ </div>
+ </Show>
+ <Show when={submission.result && submission.result.error}>
+ {(err) => <div data-slot="form-error">{err()}</div>}
+ </Show>
+ <div data-slot="form-actions">
+ <button type="button" data-color="ghost" onClick={() => setEditing(false)}>
+ Cancel
+ </button>
+ <Show when={!isCurrentUser()}>
+ <button type="submit" data-color="primary" disabled={submission.pending}>
+ {submission.pending ? "Saving..." : "Save"}
+ </button>
+ </Show>
+ </div>
+ </form>
+ </td>
+ </tr>
+ </Show>
+ )
+}
+
export function MemberSection() {
const params = useParams()
- const members = createAsync(() => listMembers(params.id))
+ const data = createAsync(() => listMembers(params.id))
return (
<section class={styles.root}>
@@ -157,7 +325,7 @@ export function MemberSection() {
<MemberCreateForm />
<div data-slot="members-table">
<Show
- when={members()?.length}
+ when={data()?.members.length}
fallback={
<div data-component="empty-state">
<p>Invite a member to your workspace</p>
@@ -169,32 +337,15 @@ export function MemberSection() {
<tr>
<th>Email</th>
<th>Role</th>
- <th>Joined</th>
+ <th></th>
<th></th>
</tr>
</thead>
<tbody>
- <For each={members()!}>
- {(member) => {
- return (
- <tr>
- <td data-slot="member-email">{member.email}</td>
- <td data-slot="member-role">{member.role}</td>
- <Show when={member.timeSeen} fallback={<td data-slot="member-joined">invited</td>}>
- <td data-slot="member-joined" title={formatDateUTC(member.timeSeen!)}>
- {formatDateForTable(member.timeSeen!)}
- </td>
- </Show>
- <td data-slot="member-actions">
- <form action={removeMember} method="post">
- <input type="hidden" name="id" value={member.id} />
- <input type="hidden" name="workspaceID" value={params.id} />
- <button data-color="ghost">Delete</button>
- </form>
- </td>
- </tr>
- )
- }}
+ <For each={data()!.members}>
+ {(member) => (
+ <MemberRow member={member} workspaceID={params.id} currentUserID={data()!.currentUserID} />
+ )}
</For>
</tbody>
</table>
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 <[email protected]>`,
+ 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 <JEText {...props} style={{ ...textColor, ...props.style }} />
+}
+
+export function Hr(props: HrProps) {
+ return <JEHr {...props} style={{ borderTop: `1px solid ${DIVIDER_COLOR}`, ...props.style }} />
+}
+
+export function SurfaceHr(props: HrProps) {
+ return (
+ <JEHr
+ {...props}
+ style={{
+ borderTop: `1px solid ${SURFACE_DIVIDER_COLOR}`,
+ ...props.style,
+ }}
+ />
+ )
+}
+
+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 (
+ <>
+ <Font
+ fontFamily="IBM Plex Mono"
+ fallbackFontFamily="monospace"
+ webFont={{
+ url: `${assetsUrl}/ibm-plex-mono-latin-400.woff2`,
+ format: "woff2",
+ }}
+ fontWeight="400"
+ fontStyle="normal"
+ />
+ <Font
+ fontFamily="IBM Plex Mono"
+ fallbackFontFamily="monospace"
+ webFont={{
+ url: `${assetsUrl}/ibm-plex-mono-latin-500.woff2`,
+ format: "woff2",
+ }}
+ fontWeight="500"
+ fontStyle="normal"
+ />
+ <Font
+ fontFamily="IBM Plex Mono"
+ fallbackFontFamily="monospace"
+ webFont={{
+ url: `${assetsUrl}/ibm-plex-mono-latin-600.woff2`,
+ format: "woff2",
+ }}
+ fontWeight="600"
+ fontStyle="normal"
+ />
+ <Font
+ fontFamily="IBM Plex Mono"
+ fallbackFontFamily="monospace"
+ webFont={{
+ url: `${assetsUrl}/ibm-plex-mono-latin-700.woff2`,
+ format: "woff2",
+ }}
+ fontWeight="700"
+ fontStyle="normal"
+ />
+ <Font
+ fontFamily="Rubik"
+ fallbackFontFamily={["Helvetica", "Arial", "sans-serif"]}
+ webFont={{
+ url: `${assetsUrl}/rubik-latin.woff2`,
+ format: "woff2",
+ }}
+ fontWeight="400 500 600 700"
+ fontStyle="normal"
+ />
+ </>
+ )
+}
+
+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(<Wbr key={`${i}wbr`} />)
+ }
+ }
+ 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 (
+ <Html lang="en">
+ <Head>
+ <Title>{`OpenCode Zen — ${messagePlain}`}</Title>
+ </Head>
+ <Fonts assetsUrl={assetsUrl} />
+ <Preview>{messagePlain}</Preview>
+ <Body style={body} id={Math.random().toString()}>
+ <Container style={container}>
+ <Section style={frame}>
+ <Row>
+ <Column>
+ <A href={CONSOLE_URL}>
+ <Img height="32" alt="OpenCode Zen Logo" src={`${assetsUrl}/zen-logo.png`} />
+ </A>
+ </Column>
+ <Column align="right">
+ <Button style={buttonPrimary} href={url}>
+ <Span style={code}>Join Workspace</Span>
+ </Button>
+ </Column>
+ </Row>
+
+ <Row style={headingHr}>
+ <Column>
+ <Hr />
+ </Column>
+ </Row>
+
+ <Section>
+ <Text style={{ ...compactText, ...breadcrumb }}>
+ <Span>OpenCode Zen</Span>
+ <Span style={{ ...code, ...breadcrumbColonSeparator }}>:</Span>
+ <Span>{workspace}</Span>
+ </Text>
+ <Text style={{ ...heading, ...compactText }}>
+ <Link href={url}>
+ <SplitString text={subject} split={40} />
+ </Link>
+ </Text>
+ </Section>
+ <Section style={{ padding: `${unit}px 0 0 0` }}>
+ <Text style={{ ...compactText }}>
+ You've been invited to join the{" "}
+ <Link style={medium} href={url}>
+ {workspace}
+ </Link>{" "}
+ workspace in the{" "}
+ <Link style={medium} href={CONSOLE_URL}>
+ OpenCode Zen Console
+ </Link>
+ .
+ </Text>
+ </Section>
+
+ <Row style={headingHr}>
+ <Column>
+ <Hr />
+ </Column>
+ </Row>
+
+ <Row>
+ <Column>
+ <Link href={CONSOLE_URL} style={footerLink}>
+ Console
+ </Link>
+ </Column>
+ <Column align="right">
+ <Link style={footerLink} href={DOC_URL}>
+ About
+ </Link>
+ </Column>
+ </Row>
+ </Section>
+ </Container>
+ </Body>
+ </Html>
+ )
+}
+
+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
--- /dev/null
+++ b/packages/console/mail/emails/templates/static/ibm-plex-mono-latin-400.woff2
Binary files 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
--- /dev/null
+++ b/packages/console/mail/emails/templates/static/ibm-plex-mono-latin-500.woff2
Binary files 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
--- /dev/null
+++ b/packages/console/mail/emails/templates/static/ibm-plex-mono-latin-600.woff2
Binary files 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
--- /dev/null
+++ b/packages/console/mail/emails/templates/static/ibm-plex-mono-latin-700.woff2
Binary files 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
--- /dev/null
+++ b/packages/console/mail/emails/templates/static/rubik-latin.woff2
Binary files 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
--- /dev/null
+++ b/packages/console/mail/emails/templates/static/zen-logo.png
Binary files 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 */
+
+/// <reference path="../../../sst-env.d.ts" />
+
+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
diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts
index 0cd862dff..6a5d2bbf4 100644
--- a/packages/function/sst-env.d.ts
+++ b/packages/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