Frank 6 bulan lalu
induk
melakukan
70da3a9399
33 mengubah file dengan 1823 tambahan dan 109 penghapusan
  1. 375 14
      bun.lock
  2. 13 1
      infra/console.ts
  3. 2 0
      packages/console/app/package.json
  4. 1 0
      packages/console/app/public/email
  5. 11 5
      packages/console/app/src/context/auth.ts
  6. 34 7
      packages/console/app/src/routes/workspace/[id].tsx
  7. 216 65
      packages/console/app/src/routes/workspace/member-section.tsx
  8. 2 0
      packages/console/core/migrations/0021_flawless_clea.sql
  9. 702 0
      packages/console/core/migrations/meta/0021_snapshot.json
  10. 7 0
      packages/console/core/migrations/meta/_journal.json
  11. 1 0
      packages/console/core/package.json
  12. 1 7
      packages/console/core/src/account.ts
  13. 0 2
      packages/console/core/src/actor.ts
  14. 63 0
      packages/console/core/src/aws.ts
  15. 1 1
      packages/console/core/src/billing.ts
  16. 2 1
      packages/console/core/src/schema/user.sql.ts
  17. 0 1
      packages/console/core/src/workspace.ts
  18. 1 5
      packages/console/function/src/auth.ts
  19. 8 0
      packages/console/function/sst-env.d.ts
  20. 108 0
      packages/console/mail/emails/components.tsx
  21. 110 0
      packages/console/mail/emails/styles.ts
  22. 113 0
      packages/console/mail/emails/templates/InviteEmail.tsx
  23. TEMPAT SAMPAH
      packages/console/mail/emails/templates/static/ibm-plex-mono-latin-400.woff2
  24. TEMPAT SAMPAH
      packages/console/mail/emails/templates/static/ibm-plex-mono-latin-500.woff2
  25. TEMPAT SAMPAH
      packages/console/mail/emails/templates/static/ibm-plex-mono-latin-600.woff2
  26. TEMPAT SAMPAH
      packages/console/mail/emails/templates/static/ibm-plex-mono-latin-700.woff2
  27. TEMPAT SAMPAH
      packages/console/mail/emails/templates/static/rubik-latin.woff2
  28. TEMPAT SAMPAH
      packages/console/mail/emails/templates/static/zen-logo.png
  29. 19 0
      packages/console/mail/package.json
  30. 9 0
      packages/console/mail/sst-env.d.ts
  31. 8 0
      packages/console/resource/sst-env.d.ts
  32. 8 0
      packages/function/sst-env.d.ts
  33. 8 0
      sst-env.d.ts

File diff ditekan karena terlalu besar
+ 375 - 14
bun.lock


+ 13 - 1
infra/console.ts

@@ -110,6 +110,9 @@ const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
 // CONSOLE
 ////////////////
 
+const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
+const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
+
 let logProcessor
 if ($app.stage === "production" || $app.stage === "frank") {
   const HONEYCOMB_API_KEY = new sst.Secret("HONEYCOMB_API_KEY")
@@ -122,7 +125,16 @@ if ($app.stage === "production" || $app.stage === "frank") {
 new sst.cloudflare.x.SolidStart("Console", {
   domain,
   path: "packages/console/app",
-  link: [database, AUTH_API_URL, STRIPE_WEBHOOK_SECRET, STRIPE_SECRET_KEY, ZEN_MODELS, EMAILOCTOPUS_API_KEY],
+  link: [
+    database,
+    AUTH_API_URL,
+    STRIPE_WEBHOOK_SECRET,
+    STRIPE_SECRET_KEY,
+    ZEN_MODELS,
+    EMAILOCTOPUS_API_KEY,
+    AWS_SES_ACCESS_KEY_ID,
+    AWS_SES_SECRET_ACCESS_KEY,
+  ],
   environment: {
     //VITE_DOCS_URL: web.url.apply((url) => url!),
     //VITE_API_URL: gateway.url.apply((url) => url!),

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

+ 1 - 0
packages/console/app/public/email

@@ -0,0 +1 @@
+../../mail/emails/templates/static

+ 11 - 5
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: {

+ 34 - 7
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)
 }

+ 216 - 65
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>

+ 2 - 0
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);

+ 702 - 0
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": {}
+  }
+}

+ 7 - 0
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
     }
   ]
 }

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

+ 1 - 7
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(),
     )
   }

+ 0 - 2
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]
     }
   }
 

+ 63 - 0
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}`)
+      }
+    },
+  )
+}

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

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

+ 0 - 1
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({

+ 1 - 5
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")
 

+ 8 - 0
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

+ 108 - 0
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}</>
+}

+ 110 - 0
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",
+};

+ 113 - 0
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

TEMPAT SAMPAH
packages/console/mail/emails/templates/static/ibm-plex-mono-latin-400.woff2


TEMPAT SAMPAH
packages/console/mail/emails/templates/static/ibm-plex-mono-latin-500.woff2


TEMPAT SAMPAH
packages/console/mail/emails/templates/static/ibm-plex-mono-latin-600.woff2


TEMPAT SAMPAH
packages/console/mail/emails/templates/static/ibm-plex-mono-latin-700.woff2


TEMPAT SAMPAH
packages/console/mail/emails/templates/static/rubik-latin.woff2


TEMPAT SAMPAH
packages/console/mail/emails/templates/static/zen-logo.png


+ 19 - 0
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"
+  }
+}

+ 9 - 0
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 {}

+ 8 - 0
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

+ 8 - 0
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

+ 8 - 0
sst-env.d.ts

@@ -9,6 +9,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
+    }
     "Api": {
       "type": "sst.cloudflare.Worker"
       "url": string

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini