Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/console/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,12 @@ export const dict = {
"workspace.settings.updating": "Updating...",
"workspace.settings.save": "Save",
"workspace.settings.edit": "Edit",
"workspace.settings.accountEmail": "Account email",
"workspace.settings.changeEmail": "Change email",
"workspace.settings.newEmailPlaceholder": "new@email.com",
"workspace.settings.sending": "Sending...",
"workspace.settings.sendConfirmations": "Send confirmations",
"workspace.settings.emailChangeSent": "Check both email addresses to complete the change.",

"workspace.billing.title": "Billing",
"workspace.billing.subtitle.beforeLink": "Manage payment methods.",
Expand Down
27 changes: 27 additions & 0 deletions packages/console/app/src/routes/account/email/confirm/[token].ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { redirect } from "@solidjs/router"
import type { APIEvent } from "@solidjs/start"
import { EmailChange } from "@opencode-ai/console-core/email-change.js"
import { useAuthSession } from "~/context/auth"

export async function GET(input: APIEvent) {
const result = await EmailChange.confirm({ token: input.params.token }).catch(() => undefined)
if (!result) return redirect("/workspace?emailChange=error")
if (!result.complete) return redirect("/workspace?emailChange=pending")

const session = await useAuthSession()
const current = session.data.current
if (current && session.data.account?.[current]) {
await session.update((value) => ({
...value,
account: {
...value.account,
[current]: {
...value.account?.[current],
id: current,
email: result.email,
},
},
}))
}
return redirect("/workspace")
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import { Database, eq } from "@opencode-ai/console-core/drizzle/index.js"
import { WorkspaceTable } from "@opencode-ai/console-core/schema/workspace.sql.js"
import { useI18n } from "~/context/i18n"
import { formError, localizeError } from "~/lib/form-error"
import { EmailChange } from "@opencode-ai/console-core/email-change.js"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { getActor } from "~/context/auth"

const getWorkspaceInfo = query(async (workspaceID: string) => {
"use server"
Expand All @@ -28,6 +31,12 @@ const getWorkspaceInfo = query(async (workspaceID: string) => {
)
}, "workspace.get")

const getAccountEmail = query(async () => {
"use server"
const actor = await getActor()
return actor.type === "account" ? actor.properties.email : null
}, "account.email")

const updateWorkspace = action(async (form: FormData) => {
"use server"
const name = (form.get("name") as string | null)?.trim()
Expand All @@ -46,14 +55,32 @@ const updateWorkspace = action(async (form: FormData) => {
)
}, "workspace.update")

const requestEmailChange = action(async (form: FormData) => {
"use server"
const newEmail = (form.get("email") as string | null)?.trim()
if (!newEmail) return { error: formError.emailRequired }
const actor = await getActor()
if (actor.type !== "account") return { error: "Expected account actor" }
return json(
await Actor.provide("account", actor.properties, () =>
EmailChange.request({ newEmail })
.then(() => ({ error: undefined }))
.catch((e) => ({ error: e.message as string })),
),
)
}, "account.email.change")

export function SettingsSection() {
const params = useParams()
const i18n = useI18n()
const workspaceInfo = createAsync(() => getWorkspaceInfo(params.id!))
const accountEmail = createAsync(() => getAccountEmail())
const submission = useSubmission(updateWorkspace)
const [store, setStore] = createStore({ show: false })
const emailSubmission = useSubmission(requestEmailChange)
const [store, setStore] = createStore({ show: false, showEmail: false })

let input: HTMLInputElement
let emailInput: HTMLInputElement

createEffect(() => {
if (!submission.pending && submission.result && !submission.result.error) {
Expand All @@ -67,13 +94,26 @@ export function SettingsSection() {
if (!submission.result) break
}
setStore("show", true)
input.focus()
queueMicrotask(() => input.focus())
}

function hide() {
setStore("show", false)
}

function showEmail() {
while (true) {
emailSubmission.clear()
if (!emailSubmission.result) break
}
setStore("showEmail", true)
queueMicrotask(() => emailInput.focus())
}

function hideEmail() {
setStore("showEmail", false)
}

return (
<section class={styles.root}>
<div data-slot="section-title">
Expand Down Expand Up @@ -119,6 +159,47 @@ export function SettingsSection() {
</div>
</Show>
</div>
<div data-slot="setting">
<p>{i18n.t("workspace.settings.accountEmail")}</p>
<Show
when={!store.showEmail}
fallback={
<form action={requestEmailChange} method="post" data-slot="create-form">
<div data-slot="input-container">
<input
required
ref={(r) => (emailInput = r)}
data-component="input"
name="email"
type="email"
placeholder={i18n.t("workspace.settings.newEmailPlaceholder")}
/>
<button type="submit" data-color="primary" disabled={emailSubmission.pending}>
{emailSubmission.pending
? i18n.t("workspace.settings.sending")
: i18n.t("workspace.settings.sendConfirmations")}
</button>
<button type="reset" data-color="ghost" onClick={() => hideEmail()}>
{i18n.t("common.cancel")}
</button>
</div>
<Show when={emailSubmission.result && emailSubmission.result.error}>
{(err) => <div data-slot="form-error">{localizeError(i18n.t, err())}</div>}
</Show>
<Show when={emailSubmission.result && !emailSubmission.result.error}>
<div data-slot="form-success">{i18n.t("workspace.settings.emailChangeSent")}</div>
</Show>
</form>
}
>
<div data-slot="value-with-action">
<p data-slot="current-value">{accountEmail()}</p>
<button data-color="primary" onClick={() => showEmail()}>
{i18n.t("workspace.settings.changeEmail")}
</button>
</div>
</Show>
</div>
</div>
</section>
)
Expand Down
163 changes: 163 additions & 0 deletions packages/console/core/src/email-change.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { render } from "@jsx-email/render"
import { z } from "zod"
import { and, Database, eq, isNull, or, sql } from "./drizzle"
import { Actor } from "./actor"
import { AWS } from "./aws"
import { fn } from "./util/fn"
import { Identifier } from "./identifier"
import { AuthTable } from "./schema/auth.sql"
import { EmailChangeTable } from "./schema/email-change.sql"

const EXPIRY_HOURS = 24
const CONSOLE_URL = "https://opencode.ai"

const email = z.string().email().max(255).transform((value) => value.toLowerCase())

const token = () => Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url")
const hash = async (value: string) =>
Buffer.from(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))).toString("hex")

export namespace EmailChange {
export function nextConfirmationState(input: {
oldTokenHash: string
newTokenHash: string
tokenHash: string
oldConfirmedAt: Date | null
newConfirmedAt: Date | null
now: Date
}) {
const oldConfirmedAt = input.oldTokenHash === input.tokenHash ? input.now : input.oldConfirmedAt
const newConfirmedAt = input.newTokenHash === input.tokenHash ? input.now : input.newConfirmedAt
return {
oldConfirmedAt,
newConfirmedAt,
complete: !!oldConfirmedAt && !!newConfirmedAt,
}
}

export const request = fn(z.object({ newEmail: email }), async (input) => {
const actor = Actor.assert("account")
const newEmail = input.newEmail

const oldToken = token()
const newToken = token()
const id = Identifier.create("emailChange")
const oldEmail = await Database.transaction(async (tx) => {
const current = await tx
.select({ email: AuthTable.subject })
.from(AuthTable)
.where(
and(
eq(AuthTable.provider, "email"),
eq(AuthTable.accountID, actor.properties.accountID),
isNull(AuthTable.timeDeleted),
),
)
.then((rows) => rows[0])
if (!current) throw new Error("Current email not found")
if (newEmail === current.email) throw new Error("New email must be different from current email")

const existing = await tx
.select({ accountID: AuthTable.accountID })
.from(AuthTable)
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, newEmail)))
.then((rows) => rows[0])
if (existing) throw new Error("Email is already in use")

await tx
.update(EmailChangeTable)
.set({ cancelledAt: sql`now()` })
.where(
and(
eq(EmailChangeTable.accountID, actor.properties.accountID),
isNull(EmailChangeTable.completedAt),
isNull(EmailChangeTable.cancelledAt),
),
)

await tx.insert(EmailChangeTable).values({
id,
accountID: actor.properties.accountID,
oldEmail: current.email,
newEmail,
oldTokenHash: await hash(oldToken),
newTokenHash: await hash(newToken),
expiresAt: sql`DATE_ADD(now(), INTERVAL ${EXPIRY_HOURS} HOUR)`,
})
return current.email
})

const { EmailChangeConfirmEmail } = await import("@opencode-ai/console-mail/EmailChangeConfirmEmail.jsx")
const oldUrl = `${CONSOLE_URL}/account/email/confirm/${oldToken}`
const newUrl = `${CONSOLE_URL}/account/email/confirm/${newToken}`

await Promise.all([
AWS.sendEmail({
to: oldEmail,
subject: "Confirm your OpenCode email change",
body: render(
// @ts-ignore
EmailChangeConfirmEmail({ oldEmail, newEmail, url: oldUrl, kind: "old" }),
),
}),
AWS.sendEmail({
to: newEmail,
subject: "Verify your new OpenCode email",
body: render(
// @ts-ignore
EmailChangeConfirmEmail({ oldEmail, newEmail, url: newUrl, kind: "new" }),
),
}),
])
})

export const confirm = fn(z.object({ token: z.string().min(1) }), async (input) => {
const tokenHash = await hash(input.token)
return Database.transaction(async (tx) => {
const change = await tx
.select()
.from(EmailChangeTable)
.where(
and(
or(eq(EmailChangeTable.oldTokenHash, tokenHash), eq(EmailChangeTable.newTokenHash, tokenHash)),
isNull(EmailChangeTable.completedAt),
isNull(EmailChangeTable.cancelledAt),
),
)
.then((rows) => rows[0])
if (!change) throw new Error("Email change request not found")
if (change.expiresAt < new Date()) throw new Error("Email change request expired")

const next = nextConfirmationState({
oldTokenHash: change.oldTokenHash,
newTokenHash: change.newTokenHash,
tokenHash,
oldConfirmedAt: change.oldConfirmedAt,
newConfirmedAt: change.newConfirmedAt,
now: new Date(),
})

await tx
.update(EmailChangeTable)
.set({ oldConfirmedAt: next.oldConfirmedAt, newConfirmedAt: next.newConfirmedAt })
.where(eq(EmailChangeTable.id, change.id))

if (!next.complete) return { complete: false, email: change.newEmail }

const existing = await tx
.select({ accountID: AuthTable.accountID })
.from(AuthTable)
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, change.newEmail)))
.then((rows) => rows[0])
if (existing && existing.accountID !== change.accountID) throw new Error("Email is already in use")

await tx
.update(AuthTable)
.set({ subject: change.newEmail })
.where(and(eq(AuthTable.provider, "email"), eq(AuthTable.accountID, change.accountID)))

await tx.update(EmailChangeTable).set({ completedAt: sql`now()` }).where(eq(EmailChangeTable.id, change.id))
return { complete: true, email: change.newEmail }
})
})
}
1 change: 1 addition & 0 deletions packages/console/core/src/identifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export namespace Identifier {
auth: "aut",
benchmark: "ben",
billing: "bil",
emailChange: "emc",
key: "key",
lite: "lit",
model: "mod",
Expand Down
27 changes: 27 additions & 0 deletions packages/console/core/src/schema/email-change.sql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { index, mysqlTable, primaryKey, uniqueIndex, varchar } from "drizzle-orm/mysql-core"
import { id, timestamps, ulid, utc } from "../drizzle/types"

export const EmailChangeTable = mysqlTable(
"email_change",
{
id: id(),
...timestamps,
accountID: ulid("account_id").notNull(),
oldEmail: varchar("old_email", { length: 255 }).notNull(),
newEmail: varchar("new_email", { length: 255 }).notNull(),
oldTokenHash: varchar("old_token_hash", { length: 64 }).notNull(),
newTokenHash: varchar("new_token_hash", { length: 64 }).notNull(),
oldConfirmedAt: utc("old_confirmed_at"),
newConfirmedAt: utc("new_confirmed_at"),
completedAt: utc("completed_at"),
cancelledAt: utc("cancelled_at"),
expiresAt: utc("expires_at").notNull(),
},
(table) => [
primaryKey({ columns: [table.id] }),
uniqueIndex("old_token_hash").on(table.oldTokenHash),
uniqueIndex("new_token_hash").on(table.newTokenHash),
index("account_id").on(table.accountID),
index("new_email").on(table.newEmail),
],
)
Loading
Loading