This commit is contained in:
Frank
2025-10-01 19:34:37 -04:00
parent 1024537b47
commit 70da3a9399
33 changed files with 2018 additions and 116 deletions

589
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -110,6 +110,9 @@ const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
// CONSOLE // 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 let logProcessor
if ($app.stage === "production" || $app.stage === "frank") { if ($app.stage === "production" || $app.stage === "frank") {
const HONEYCOMB_API_KEY = new sst.Secret("HONEYCOMB_API_KEY") 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", { new sst.cloudflare.x.SolidStart("Console", {
domain, domain,
path: "packages/console/app", 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: { environment: {
//VITE_DOCS_URL: web.url.apply((url) => url!), //VITE_DOCS_URL: web.url.apply((url) => url!),
//VITE_API_URL: gateway.url.apply((url) => url!), //VITE_API_URL: gateway.url.apply((url) => url!),

View File

@@ -11,8 +11,10 @@
}, },
"dependencies": { "dependencies": {
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
"@jsx-email/render": "1.1.1",
"@openauthjs/openauth": "0.0.0-20250322224806", "@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*", "@opencode/console-core": "workspace:*",
"@opencode/console-mail": "workspace:*",
"@solidjs/meta": "^0.29.4", "@solidjs/meta": "^0.29.4",
"@solidjs/router": "^0.15.0", "@solidjs/router": "^0.15.0",
"@solidjs/start": "^1.1.0", "@solidjs/start": "^1.1.0",

View File

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

View File

@@ -1,5 +1,5 @@
import { getRequestEvent } from "solid-js/web" 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 { WorkspaceTable } from "@opencode/console-core/schema/workspace.sql.js"
import { UserTable } from "@opencode/console-core/schema/user.sql.js" import { UserTable } from "@opencode/console-core/schema/user.sql.js"
import { redirect } from "@solidjs/router" 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 ?? {}) const accounts = Object.keys(auth.data.account ?? {})
if (accounts.length) { if (accounts.length) {
const result = await Database.transaction(async (tx) => { const result = await Database.use((tx) =>
return await tx tx
.select({ .select({
user: UserTable, 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))) .where(and(inArray(AccountTable.id, accounts), eq(WorkspaceTable.id, workspace)))
.limit(1) .limit(1)
.execute() .execute()
.then((x) => x[0]) .then((x) => x[0]),
}) )
if (result) { if (result) {
await Database.use((tx) =>
tx
.update(UserTable)
.set({ timeSeen: sql`now()` })
.where(eq(UserTable.id, result.user.id)),
)
return { return {
type: "user", type: "user",
properties: { properties: {

View File

@@ -7,10 +7,33 @@ import { UsageSection } from "./usage-section"
import { KeySection } from "./key-section" import { KeySection } from "./key-section"
import { MemberSection } from "./member-section" import { MemberSection } from "./member-section"
import { Show } from "solid-js" 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 () { export default function () {
const params = useParams() const params = useParams()
const data = createAsync(() => getUser(params.id))
return ( return (
<div data-page="workspace-[id]"> <div data-page="workspace-[id]">
<section data-component="title-section"> <section data-component="title-section">
@@ -27,13 +50,17 @@ export default function () {
<div data-slot="sections"> <div data-slot="sections">
<NewUserSection /> <NewUserSection />
<KeySection /> <KeySection />
<Show when={isBeta(params.id)}> <Show when={data()?.isAdmin}>
<MemberSection /> <Show when={isBeta(params.id)}>
<MemberSection />
</Show>
<BillingSection />
<MonthlyLimitSection />
</Show> </Show>
<BillingSection />
<MonthlyLimitSection />
<UsageSection /> <UsageSection />
<PaymentSection /> <Show when={data()?.isAdmin}>
<PaymentSection />
</Show>
</div> </div>
</div> </div>
) )
@@ -43,6 +70,6 @@ export function isBeta(workspaceID: string) {
return [ return [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // production "wrk_01K46JDFR0E75SG2Q8K172KF3Y", // production
"wrk_01K4NFRR5P7FSYWH88307B4DDS", // dev "wrk_01K4NFRR5P7FSYWH88307B4DDS", // dev
"wrk_01K68M8J1KK0PJ39H59B1EGHP6", // frank "wrk_01K6G7HBZ7C046A4XK01CVD0NS", // frank
].includes(workspaceID) ].includes(workspaceID)
} }

View File

@@ -2,32 +2,49 @@ import { json, query, action, useParams, createAsync, useSubmission } from "@sol
import { createEffect, createSignal, For, Show } from "solid-js" import { createEffect, createSignal, For, Show } from "solid-js"
import { withActor } from "~/context/auth.withActor" import { withActor } from "~/context/auth.withActor"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { formatDateUTC, formatDateForTable } from "./common"
import styles from "./member-section.module.css" 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 { UserTable, UserRole } from "@opencode/console-core/schema/user.sql.js"
import { Identifier } from "@opencode/console-core/identifier.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 removeMember = action(async (form: FormData) => { 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" "use server"
const id = form.get("id")?.toString() return withActor(async () => {
if (!id) return { error: "ID is required" } const actor = await assertAdmin(workspaceID)
const workspaceID = form.get("workspaceID")?.toString() return Database.use((tx) =>
if (!workspaceID) return { error: "Workspace ID is required" } tx
return json( .select()
await withActor( .from(UserTable)
() => .where(and(eq(UserTable.workspaceID, workspaceID), isNull(UserTable.timeDeleted))),
Database.use((tx) => ).then((members) => ({
tx members,
.update(UserTable) currentUserID: actor.properties.userID,
.set({ timeDeleted: sql`now()` }) }))
.where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, workspaceID))), }, workspaceID)
), }, "member.list")
workspaceID,
),
{ revalidate: listMembers.key },
)
}, "member.remove")
const inviteMember = action(async (form: FormData) => { const inviteMember = action(async (form: FormData) => {
"use server" "use server"
@@ -38,34 +55,105 @@ const inviteMember = action(async (form: FormData) => {
const role = form.get("role")?.toString() as (typeof UserRole)[number] const role = form.get("role")?.toString() as (typeof UserRole)[number]
if (!role) return { error: "Role is required" } if (!role) return { error: "Role is required" }
return json( return json(
await withActor( await withActor(async () => {
() => await assertAdmin(workspaceID)
Database.use((tx) => return Database.use((tx) =>
tx tx
.insert(UserTable) .insert(UserTable)
.values({ .values({
id: Identifier.create("user"), id: Identifier.create("user"),
name: "", name: "",
email, email,
workspaceID, workspaceID,
role, 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,
}),
),
}) })
.then((data) => ({ error: undefined, data })) return data
.catch((e) => ({ error: e.message as string })), })
), .catch((e) => {
workspaceID, 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 }, { revalidate: listMembers.key },
) )
}, "member.create") }, "member.create")
const listMembers = query(async (workspaceID: string) => { const removeMember = action(async (form: FormData) => {
"use server" "use server"
return withActor( const id = form.get("id")?.toString()
() => Database.use((tx) => tx.select().from(UserTable).where(eq(UserTable.workspaceID, workspaceID))), if (!id) return { error: "ID is required" }
workspaceID, const workspaceID = form.get("workspaceID")?.toString()
if (!workspaceID) return { error: "Workspace ID is required" }
return json(
await withActor(async () => {
await assertAdmin(workspaceID)
assertNotSelf(id)
return Database.transaction(async (tx) => {
const email = await tx
.select({ email: UserTable.email })
.from(UserTable)
.where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, workspaceID)))
.execute()
.then((rows) => rows[0].email)
if (!email) return { error: "User not found" }
await tx
.update(UserTable)
.set({
oldEmail: email,
email: null,
timeDeleted: sql`now()`,
})
.where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, workspaceID)))
})
.then(() => ({ error: undefined }))
.catch((e) => ({ error: e.message as string }))
}, workspaceID),
{ revalidate: listMembers.key },
) )
}, "member.list") }, "member.remove")
const updateMemberRole = action(async (form: FormData) => {
"use server"
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(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.updateRole")
export function MemberCreateForm() { export function MemberCreateForm() {
const params = useParams() 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() { export function MemberSection() {
const params = useParams() const params = useParams()
const members = createAsync(() => listMembers(params.id)) const data = createAsync(() => listMembers(params.id))
return ( return (
<section class={styles.root}> <section class={styles.root}>
@@ -157,7 +325,7 @@ export function MemberSection() {
<MemberCreateForm /> <MemberCreateForm />
<div data-slot="members-table"> <div data-slot="members-table">
<Show <Show
when={members()?.length} when={data()?.members.length}
fallback={ fallback={
<div data-component="empty-state"> <div data-component="empty-state">
<p>Invite a member to your workspace</p> <p>Invite a member to your workspace</p>
@@ -169,32 +337,15 @@ export function MemberSection() {
<tr> <tr>
<th>Email</th> <th>Email</th>
<th>Role</th> <th>Role</th>
<th>Joined</th> <th></th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<For each={members()!}> <For each={data()!.members}>
{(member) => { {(member) => (
return ( <MemberRow member={member} workspaceID={params.id} currentUserID={data()!.currentUserID} />
<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> </For>
</tbody> </tbody>
</table> </table>

View File

@@ -0,0 +1,2 @@
ALTER TABLE `user` MODIFY COLUMN `email` varchar(255);--> statement-breakpoint
ALTER TABLE `user` ADD `old_email` varchar(255);

View File

@@ -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": {}
}
}

View File

@@ -148,6 +148,13 @@
"when": 1759169697658, "when": 1759169697658,
"tag": "0020_supreme_jack_power", "tag": "0020_supreme_jack_power",
"breakpoints": true "breakpoints": true
},
{
"idx": 21,
"version": "5",
"when": 1759186023755,
"tag": "0021_flawless_clea",
"breakpoints": true
} }
] ]
} }

View File

@@ -8,6 +8,7 @@
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@opencode/console-resource": "workspace:*", "@opencode/console-resource": "workspace:*",
"@planetscale/database": "1.19.0", "@planetscale/database": "1.19.0",
"aws4fetch": "1.0.20",
"drizzle-orm": "0.41.0", "drizzle-orm": "0.41.0",
"postgres": "3.4.7", "postgres": "3.4.7",
"stripe": "18.0.0", "stripe": "18.0.0",

View File

@@ -54,13 +54,7 @@ export namespace Account {
.select(getTableColumns(WorkspaceTable)) .select(getTableColumns(WorkspaceTable))
.from(WorkspaceTable) .from(WorkspaceTable)
.innerJoin(UserTable, eq(UserTable.workspaceID, WorkspaceTable.id)) .innerJoin(UserTable, eq(UserTable.workspaceID, WorkspaceTable.id))
.where( .where(and(eq(UserTable.email, actor.properties.email), isNull(WorkspaceTable.timeDeleted)))
and(
eq(UserTable.email, actor.properties.email),
isNull(UserTable.timeDeleted),
isNull(WorkspaceTable.timeDeleted),
),
)
.execute(), .execute(),
) )
} }

View File

@@ -1,5 +1,4 @@
import { Context } from "./context" import { Context } from "./context"
import { UserRole } from "./schema/user.sql"
import { Log } from "./util/log" import { Log } from "./util/log"
export namespace Actor { export namespace Actor {
@@ -21,7 +20,6 @@ export namespace Actor {
properties: { properties: {
userID: string userID: string
workspaceID: string workspaceID: string
role: (typeof UserRole)[number]
} }
} }

View File

@@ -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 <contact@anoma.ly>`,
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}`)
}
},
)
}

View File

@@ -206,7 +206,7 @@ export namespace Billing {
}, },
} }
: { : {
customer_email: user.email, customer_email: user.email!,
customer_creation: "always", customer_creation: "always",
}), }),
currency: "usd", currency: "usd",

View File

@@ -9,7 +9,8 @@ export const UserTable = mysqlTable(
{ {
...workspaceColumns, ...workspaceColumns,
...timestamps, ...timestamps,
email: varchar("email", { length: 255 }).notNull(), email: varchar("email", { length: 255 }),
oldEmail: varchar("old_email", { length: 255 }),
name: varchar("name", { length: 255 }).notNull(), name: varchar("name", { length: 255 }).notNull(),
timeSeen: utc("time_seen"), timeSeen: utc("time_seen"),
color: int("color"), color: int("color"),

View File

@@ -21,7 +21,6 @@ export namespace Workspace {
id: Identifier.create("user"), id: Identifier.create("user"),
email: account.properties.email, email: account.properties.email,
name: "", name: "",
timeSeen: sql`now()`,
role: "admin", role: "admin",
}) })
await tx.insert(BillingTable).values({ await tx.insert(BillingTable).values({

View File

@@ -111,11 +111,7 @@ export default {
} else if (response.provider === "google") { } else if (response.provider === "google") {
if (!response.id.email_verified) throw new Error("Google email not verified") if (!response.id.email_verified) throw new Error("Google email not verified")
email = response.id.email as string email = response.id.email as string
} } else throw new Error("Unsupported provider")
//if (response.provider === "email") {
// email = response.claims.email
//}
else throw new Error("Unsupported provider")
if (!email) throw new Error("No email found") if (!email) throw new Error("No email found")

View File

@@ -10,6 +10,14 @@ declare module "sst" {
"type": "sst.sst.Linkable" "type": "sst.sst.Linkable"
"value": string "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": { "Console": {
"type": "sst.cloudflare.SolidStart" "type": "sst.cloudflare.SolidStart"
"url": string "url": string

View File

@@ -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}</>
}

View File

@@ -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",
};

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

View File

@@ -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
packages/console/mail/sst-env.d.ts vendored Normal file
View File

@@ -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 {}

View File

@@ -10,6 +10,14 @@ declare module "sst" {
"type": "sst.sst.Linkable" "type": "sst.sst.Linkable"
"value": string "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": { "Console": {
"type": "sst.cloudflare.SolidStart" "type": "sst.cloudflare.SolidStart"
"url": string "url": string

View File

@@ -10,6 +10,14 @@ declare module "sst" {
"type": "sst.sst.Linkable" "type": "sst.sst.Linkable"
"value": string "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": { "Console": {
"type": "sst.cloudflare.SolidStart" "type": "sst.cloudflare.SolidStart"
"url": string "url": string

8
sst-env.d.ts vendored
View File

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