user.ts 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import { z } from "zod"
  2. import { and, eq, getTableColumns, isNull, sql } from "drizzle-orm"
  3. import { fn } from "./util/fn"
  4. import { Database } from "./drizzle"
  5. import { UserRole, UserTable } from "./schema/user.sql"
  6. import { Actor } from "./actor"
  7. import { Identifier } from "./identifier"
  8. import { render } from "@jsx-email/render"
  9. import { AWS } from "./aws"
  10. import { Key } from "./key"
  11. import { KeyTable } from "./schema/key.sql"
  12. import { WorkspaceTable } from "./schema/workspace.sql"
  13. import { AuthTable } from "./schema/auth.sql"
  14. import { AccountTable } from "./schema/account.sql"
  15. export namespace User {
  16. const assertNotSelf = (id: string) => {
  17. if (Actor.userID() !== id) return
  18. throw new Error(`Expected not self actor, got self actor`)
  19. }
  20. export const list = fn(z.void(), () =>
  21. Database.use((tx) =>
  22. tx
  23. .select({
  24. ...getTableColumns(UserTable),
  25. authEmail: AuthTable.subject,
  26. })
  27. .from(UserTable)
  28. .leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
  29. .where(and(eq(UserTable.workspaceID, Actor.workspace()), isNull(UserTable.timeDeleted))),
  30. ),
  31. )
  32. export const fromID = fn(z.string(), (id) =>
  33. Database.use((tx) =>
  34. tx
  35. .select()
  36. .from(UserTable)
  37. .where(and(eq(UserTable.workspaceID, Actor.workspace()), eq(UserTable.id, id), isNull(UserTable.timeDeleted)))
  38. .then((rows) => rows[0]),
  39. ),
  40. )
  41. export const getAuthEmail = fn(z.string(), (id) =>
  42. Database.use((tx) =>
  43. tx
  44. .select({
  45. email: AuthTable.subject,
  46. })
  47. .from(UserTable)
  48. .leftJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
  49. .where(and(eq(UserTable.workspaceID, Actor.workspace()), eq(UserTable.id, id)))
  50. .then((rows) => rows[0]?.email),
  51. ),
  52. )
  53. export const invite = fn(
  54. z.object({
  55. email: z.string(),
  56. role: z.enum(UserRole),
  57. monthlyLimit: z.number().nullable().optional(),
  58. }),
  59. async ({ email, role, monthlyLimit }) => {
  60. Actor.assertAdmin()
  61. const workspaceID = Actor.workspace()
  62. // create user
  63. const accountID = await Database.use((tx) =>
  64. tx
  65. .select({
  66. accountID: AuthTable.accountID,
  67. })
  68. .from(AuthTable)
  69. .where(and(eq(AuthTable.provider, "email"), eq(AuthTable.subject, email)))
  70. .then((rows) => rows[0]?.accountID),
  71. )
  72. await Database.use((tx) =>
  73. tx
  74. .insert(UserTable)
  75. .values({
  76. id: Identifier.create("user"),
  77. name: "",
  78. ...(accountID
  79. ? {
  80. accountID,
  81. }
  82. : {
  83. email,
  84. }),
  85. workspaceID,
  86. role,
  87. monthlyLimit,
  88. })
  89. .onDuplicateKeyUpdate({
  90. set: {
  91. role,
  92. monthlyLimit,
  93. timeDeleted: null,
  94. },
  95. }),
  96. )
  97. // create api key
  98. if (accountID) {
  99. await Database.use(async (tx) => {
  100. const user = await tx
  101. .select()
  102. .from(UserTable)
  103. .where(and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.accountID, accountID)))
  104. .then((rows) => rows[0])
  105. const key = await tx
  106. .select()
  107. .from(KeyTable)
  108. .where(and(eq(KeyTable.workspaceID, workspaceID), eq(KeyTable.userID, user.id)))
  109. .then((rows) => rows[0])
  110. if (key) return
  111. await Key.create({ userID: user.id, name: "Default API Key" })
  112. })
  113. }
  114. // send email, ignore errors
  115. try {
  116. const emailInfo = await Database.use((tx) =>
  117. tx
  118. .select({
  119. inviterEmail: AuthTable.subject,
  120. workspaceName: WorkspaceTable.name,
  121. })
  122. .from(UserTable)
  123. .innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
  124. .innerJoin(WorkspaceTable, eq(WorkspaceTable.id, workspaceID))
  125. .where(
  126. and(eq(UserTable.workspaceID, workspaceID), eq(UserTable.id, Actor.assert("user").properties.userID)),
  127. )
  128. .then((rows) => rows[0]),
  129. )
  130. const { InviteEmail } = await import("@opencode-ai/console-mail/InviteEmail.jsx")
  131. await AWS.sendEmail({
  132. to: email,
  133. subject: `You've been invited to join the ${emailInfo.workspaceName} workspace on OpenCode`,
  134. body: render(
  135. // @ts-ignore
  136. InviteEmail({
  137. inviter: emailInfo.inviterEmail,
  138. assetsUrl: `https://opencode.ai/email`,
  139. workspaceID: workspaceID,
  140. workspaceName: emailInfo.workspaceName,
  141. }),
  142. ),
  143. })
  144. } catch (e) {
  145. console.error(e)
  146. }
  147. },
  148. )
  149. export const joinInvitedWorkspaces = fn(z.void(), async () => {
  150. const account = Actor.assert("account")
  151. const invitations = await Database.use(async (tx) => {
  152. const active = await tx
  153. .select({ id: AccountTable.id })
  154. .from(AccountTable)
  155. .where(and(eq(AccountTable.id, account.properties.accountID), isNull(AccountTable.timeDeleted)))
  156. .then((rows) => rows[0])
  157. if (!active) throw new Error("Account is not active")
  158. const invitations = await tx
  159. .select({
  160. id: UserTable.id,
  161. workspaceID: UserTable.workspaceID,
  162. })
  163. .from(UserTable)
  164. .where(eq(UserTable.email, account.properties.email))
  165. await tx
  166. .update(UserTable)
  167. .set({
  168. accountID: account.properties.accountID,
  169. email: null,
  170. })
  171. .where(eq(UserTable.email, account.properties.email))
  172. return invitations
  173. })
  174. await Promise.all(
  175. invitations.map((invite) =>
  176. Actor.provide(
  177. "system",
  178. {
  179. workspaceID: invite.workspaceID,
  180. },
  181. () => Key.create({ userID: invite.id, name: "Default API Key" }),
  182. ),
  183. ),
  184. )
  185. })
  186. export const update = fn(
  187. z.object({
  188. id: z.string(),
  189. role: z.enum(UserRole),
  190. monthlyLimit: z.number().nullable(),
  191. }),
  192. async ({ id, role, monthlyLimit }) => {
  193. Actor.assertAdmin()
  194. if (role === "member") assertNotSelf(id)
  195. return await Database.use((tx) =>
  196. tx
  197. .update(UserTable)
  198. .set({ role, monthlyLimit })
  199. .where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, Actor.workspace()))),
  200. )
  201. },
  202. )
  203. export const remove = fn(z.string(), async (id) => {
  204. Actor.assertAdmin()
  205. assertNotSelf(id)
  206. return await Database.use((tx) =>
  207. tx
  208. .update(UserTable)
  209. .set({
  210. timeDeleted: sql`now()`,
  211. })
  212. .where(and(eq(UserTable.id, id), eq(UserTable.workspaceID, Actor.workspace()))),
  213. )
  214. })
  215. }