From d8b50c73e4915277da049e18cd5f6296d978aff4 Mon Sep 17 00:00:00 2001
From: Daniel Holmgren <dtholmgren@gmail.com>
Date: Thu, 13 Apr 2023 14:43:53 -0500
Subject: [PATCH] Add admin.updateAccountEamil (#812)

* -add admin capability to update account email

* pr feedback
---
 .../com/atproto/admin/updateAccountEmail.json | 25 +++++++++++++
 packages/api/src/client/index.ts              | 13 +++++++
 packages/api/src/client/lexicons.ts           | 28 ++++++++++++++
 .../com/atproto/admin/updateAccountEmail.ts   | 34 +++++++++++++++++
 .../pds/src/api/com/atproto/admin/index.ts    |  2 +
 .../com/atproto/admin/updateAccountEmail.ts   | 21 +++++++++++
 packages/pds/src/lexicon/index.ts             | 11 ++++++
 packages/pds/src/lexicon/lexicons.ts          | 28 ++++++++++++++
 .../com/atproto/admin/updateAccountEmail.ts   | 37 +++++++++++++++++++
 packages/pds/src/services/account/index.ts    |  8 ++++
 packages/pds/tests/account.test.ts            | 30 +++++++++++++++
 11 files changed, 237 insertions(+)
 create mode 100644 lexicons/com/atproto/admin/updateAccountEmail.json
 create mode 100644 packages/api/src/client/types/com/atproto/admin/updateAccountEmail.ts
 create mode 100644 packages/pds/src/api/com/atproto/admin/updateAccountEmail.ts
 create mode 100644 packages/pds/src/lexicon/types/com/atproto/admin/updateAccountEmail.ts

diff --git a/lexicons/com/atproto/admin/updateAccountEmail.json b/lexicons/com/atproto/admin/updateAccountEmail.json
new file mode 100644
index 00000000..98a66e84
--- /dev/null
+++ b/lexicons/com/atproto/admin/updateAccountEmail.json
@@ -0,0 +1,25 @@
+{
+  "lexicon": 1,
+  "id": "com.atproto.admin.updateAccountEmail",
+  "defs": {
+    "main": {
+      "type": "procedure",
+      "description": "Administrative action to update an account's email",
+      "input": {
+        "encoding": "application/json",
+        "schema": {
+          "type": "object",
+          "required": ["account", "email"],
+          "properties": {
+            "account": {
+              "type": "string",
+              "format": "at-identifier",
+              "description": "The handle or DID of the repo."
+            },
+            "email": {"type": "string"}
+          }
+        }
+      }
+    }
+  }
+}
\ No newline at end of file
diff --git a/packages/api/src/client/index.ts b/packages/api/src/client/index.ts
index 246d0c7b..84194368 100644
--- a/packages/api/src/client/index.ts
+++ b/packages/api/src/client/index.ts
@@ -20,6 +20,7 @@ import * as ComAtprotoAdminResolveModerationReports from './types/com/atproto/ad
 import * as ComAtprotoAdminReverseModerationAction from './types/com/atproto/admin/reverseModerationAction'
 import * as ComAtprotoAdminSearchRepos from './types/com/atproto/admin/searchRepos'
 import * as ComAtprotoAdminTakeModerationAction from './types/com/atproto/admin/takeModerationAction'
+import * as ComAtprotoAdminUpdateAccountEmail from './types/com/atproto/admin/updateAccountEmail'
 import * as ComAtprotoAdminUpdateAccountHandle from './types/com/atproto/admin/updateAccountHandle'
 import * as ComAtprotoIdentityResolveHandle from './types/com/atproto/identity/resolveHandle'
 import * as ComAtprotoIdentityUpdateHandle from './types/com/atproto/identity/updateHandle'
@@ -108,6 +109,7 @@ export * as ComAtprotoAdminResolveModerationReports from './types/com/atproto/ad
 export * as ComAtprotoAdminReverseModerationAction from './types/com/atproto/admin/reverseModerationAction'
 export * as ComAtprotoAdminSearchRepos from './types/com/atproto/admin/searchRepos'
 export * as ComAtprotoAdminTakeModerationAction from './types/com/atproto/admin/takeModerationAction'
+export * as ComAtprotoAdminUpdateAccountEmail from './types/com/atproto/admin/updateAccountEmail'
 export * as ComAtprotoAdminUpdateAccountHandle from './types/com/atproto/admin/updateAccountHandle'
 export * as ComAtprotoIdentityResolveHandle from './types/com/atproto/identity/resolveHandle'
 export * as ComAtprotoIdentityUpdateHandle from './types/com/atproto/identity/updateHandle'
@@ -398,6 +400,17 @@ export class AdminNS {
       })
   }
 
+  updateAccountEmail(
+    data?: ComAtprotoAdminUpdateAccountEmail.InputSchema,
+    opts?: ComAtprotoAdminUpdateAccountEmail.CallOptions,
+  ): Promise<ComAtprotoAdminUpdateAccountEmail.Response> {
+    return this._service.xrpc
+      .call('com.atproto.admin.updateAccountEmail', opts?.qp, data, opts)
+      .catch((e) => {
+        throw ComAtprotoAdminUpdateAccountEmail.toKnownErr(e)
+      })
+  }
+
   updateAccountHandle(
     data?: ComAtprotoAdminUpdateAccountHandle.InputSchema,
     opts?: ComAtprotoAdminUpdateAccountHandle.CallOptions,
diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts
index 231cfb0e..76c912cf 100644
--- a/packages/api/src/client/lexicons.ts
+++ b/packages/api/src/client/lexicons.ts
@@ -1063,6 +1063,33 @@ export const schemaDict = {
       },
     },
   },
+  ComAtprotoAdminUpdateAccountEmail: {
+    lexicon: 1,
+    id: 'com.atproto.admin.updateAccountEmail',
+    defs: {
+      main: {
+        type: 'procedure',
+        description: "Administrative action to update an account's email",
+        input: {
+          encoding: 'application/json',
+          schema: {
+            type: 'object',
+            required: ['account', 'email'],
+            properties: {
+              account: {
+                type: 'string',
+                format: 'at-identifier',
+                description: 'The handle or DID of the repo.',
+              },
+              email: {
+                type: 'string',
+              },
+            },
+          },
+        },
+      },
+    },
+  },
   ComAtprotoAdminUpdateAccountHandle: {
     lexicon: 1,
     id: 'com.atproto.admin.updateAccountHandle',
@@ -4796,6 +4823,7 @@ export const ids = {
     'com.atproto.admin.reverseModerationAction',
   ComAtprotoAdminSearchRepos: 'com.atproto.admin.searchRepos',
   ComAtprotoAdminTakeModerationAction: 'com.atproto.admin.takeModerationAction',
+  ComAtprotoAdminUpdateAccountEmail: 'com.atproto.admin.updateAccountEmail',
   ComAtprotoAdminUpdateAccountHandle: 'com.atproto.admin.updateAccountHandle',
   ComAtprotoIdentityResolveHandle: 'com.atproto.identity.resolveHandle',
   ComAtprotoIdentityUpdateHandle: 'com.atproto.identity.updateHandle',
diff --git a/packages/api/src/client/types/com/atproto/admin/updateAccountEmail.ts b/packages/api/src/client/types/com/atproto/admin/updateAccountEmail.ts
new file mode 100644
index 00000000..f023c9ae
--- /dev/null
+++ b/packages/api/src/client/types/com/atproto/admin/updateAccountEmail.ts
@@ -0,0 +1,34 @@
+/**
+ * GENERATED CODE - DO NOT MODIFY
+ */
+import { Headers, XRPCError } from '@atproto/xrpc'
+import { ValidationResult, BlobRef } from '@atproto/lexicon'
+import { isObj, hasProp } from '../../../../util'
+import { lexicons } from '../../../../lexicons'
+import { CID } from 'multiformats/cid'
+
+export interface QueryParams {}
+
+export interface InputSchema {
+  /** The handle or DID of the repo. */
+  account: string
+  email: string
+  [k: string]: unknown
+}
+
+export interface CallOptions {
+  headers?: Headers
+  qp?: QueryParams
+  encoding: 'application/json'
+}
+
+export interface Response {
+  success: boolean
+  headers: Headers
+}
+
+export function toKnownErr(e: any) {
+  if (e instanceof XRPCError) {
+  }
+  return e
+}
diff --git a/packages/pds/src/api/com/atproto/admin/index.ts b/packages/pds/src/api/com/atproto/admin/index.ts
index fefe8d26..26ccf8c4 100644
--- a/packages/pds/src/api/com/atproto/admin/index.ts
+++ b/packages/pds/src/api/com/atproto/admin/index.ts
@@ -13,6 +13,7 @@ import getModerationReports from './getModerationReports'
 import disableInviteCodes from './disableInviteCodes'
 import getInviteCodes from './getInviteCodes'
 import updateAccountHandle from './updateAccountHandle'
+import updateAccountEmail from './updateAccountEmail'
 
 export default function (server: Server, ctx: AppContext) {
   resolveModerationReports(server, ctx)
@@ -28,4 +29,5 @@ export default function (server: Server, ctx: AppContext) {
   disableInviteCodes(server, ctx)
   getInviteCodes(server, ctx)
   updateAccountHandle(server, ctx)
+  updateAccountEmail(server, ctx)
 }
diff --git a/packages/pds/src/api/com/atproto/admin/updateAccountEmail.ts b/packages/pds/src/api/com/atproto/admin/updateAccountEmail.ts
new file mode 100644
index 00000000..ad759473
--- /dev/null
+++ b/packages/pds/src/api/com/atproto/admin/updateAccountEmail.ts
@@ -0,0 +1,21 @@
+import { InvalidRequestError } from '@atproto/xrpc-server'
+import { Server } from '../../../../lexicon'
+import AppContext from '../../../../context'
+
+export default function (server: Server, ctx: AppContext) {
+  server.com.atproto.admin.updateAccountEmail({
+    auth: ctx.adminVerifier,
+    handler: async ({ input }) => {
+      await ctx.db.transaction(async (dbTxn) => {
+        const accntService = ctx.services.account(dbTxn)
+        const account = await accntService.getAccount(input.body.account)
+        if (!account) {
+          throw new InvalidRequestError(
+            `Account does not exist: ${input.body.account}`,
+          )
+        }
+        await accntService.updateEmail(account.did, input.body.email)
+      })
+    },
+  })
+}
diff --git a/packages/pds/src/lexicon/index.ts b/packages/pds/src/lexicon/index.ts
index 2820f92b..4e3667c1 100644
--- a/packages/pds/src/lexicon/index.ts
+++ b/packages/pds/src/lexicon/index.ts
@@ -21,6 +21,7 @@ import * as ComAtprotoAdminResolveModerationReports from './types/com/atproto/ad
 import * as ComAtprotoAdminReverseModerationAction from './types/com/atproto/admin/reverseModerationAction'
 import * as ComAtprotoAdminSearchRepos from './types/com/atproto/admin/searchRepos'
 import * as ComAtprotoAdminTakeModerationAction from './types/com/atproto/admin/takeModerationAction'
+import * as ComAtprotoAdminUpdateAccountEmail from './types/com/atproto/admin/updateAccountEmail'
 import * as ComAtprotoAdminUpdateAccountHandle from './types/com/atproto/admin/updateAccountHandle'
 import * as ComAtprotoIdentityResolveHandle from './types/com/atproto/identity/resolveHandle'
 import * as ComAtprotoIdentityUpdateHandle from './types/com/atproto/identity/updateHandle'
@@ -257,6 +258,16 @@ export class AdminNS {
     return this._server.xrpc.method(nsid, cfg)
   }
 
+  updateAccountEmail<AV extends AuthVerifier>(
+    cfg: ConfigOf<
+      AV,
+      ComAtprotoAdminUpdateAccountEmail.Handler<ExtractAuth<AV>>
+    >,
+  ) {
+    const nsid = 'com.atproto.admin.updateAccountEmail' // @ts-ignore
+    return this._server.xrpc.method(nsid, cfg)
+  }
+
   updateAccountHandle<AV extends AuthVerifier>(
     cfg: ConfigOf<
       AV,
diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts
index 231cfb0e..76c912cf 100644
--- a/packages/pds/src/lexicon/lexicons.ts
+++ b/packages/pds/src/lexicon/lexicons.ts
@@ -1063,6 +1063,33 @@ export const schemaDict = {
       },
     },
   },
+  ComAtprotoAdminUpdateAccountEmail: {
+    lexicon: 1,
+    id: 'com.atproto.admin.updateAccountEmail',
+    defs: {
+      main: {
+        type: 'procedure',
+        description: "Administrative action to update an account's email",
+        input: {
+          encoding: 'application/json',
+          schema: {
+            type: 'object',
+            required: ['account', 'email'],
+            properties: {
+              account: {
+                type: 'string',
+                format: 'at-identifier',
+                description: 'The handle or DID of the repo.',
+              },
+              email: {
+                type: 'string',
+              },
+            },
+          },
+        },
+      },
+    },
+  },
   ComAtprotoAdminUpdateAccountHandle: {
     lexicon: 1,
     id: 'com.atproto.admin.updateAccountHandle',
@@ -4796,6 +4823,7 @@ export const ids = {
     'com.atproto.admin.reverseModerationAction',
   ComAtprotoAdminSearchRepos: 'com.atproto.admin.searchRepos',
   ComAtprotoAdminTakeModerationAction: 'com.atproto.admin.takeModerationAction',
+  ComAtprotoAdminUpdateAccountEmail: 'com.atproto.admin.updateAccountEmail',
   ComAtprotoAdminUpdateAccountHandle: 'com.atproto.admin.updateAccountHandle',
   ComAtprotoIdentityResolveHandle: 'com.atproto.identity.resolveHandle',
   ComAtprotoIdentityUpdateHandle: 'com.atproto.identity.updateHandle',
diff --git a/packages/pds/src/lexicon/types/com/atproto/admin/updateAccountEmail.ts b/packages/pds/src/lexicon/types/com/atproto/admin/updateAccountEmail.ts
new file mode 100644
index 00000000..eb00af00
--- /dev/null
+++ b/packages/pds/src/lexicon/types/com/atproto/admin/updateAccountEmail.ts
@@ -0,0 +1,37 @@
+/**
+ * GENERATED CODE - DO NOT MODIFY
+ */
+import express from 'express'
+import { ValidationResult, BlobRef } from '@atproto/lexicon'
+import { lexicons } from '../../../../lexicons'
+import { isObj, hasProp } from '../../../../util'
+import { CID } from 'multiformats/cid'
+import { HandlerAuth } from '@atproto/xrpc-server'
+
+export interface QueryParams {}
+
+export interface InputSchema {
+  /** The handle or DID of the repo. */
+  account: string
+  email: string
+  [k: string]: unknown
+}
+
+export interface HandlerInput {
+  encoding: 'application/json'
+  body: InputSchema
+}
+
+export interface HandlerError {
+  status: number
+  message?: string
+}
+
+export type HandlerOutput = HandlerError | void
+export type Handler<HA extends HandlerAuth = never> = (ctx: {
+  auth: HA
+  params: QueryParams
+  input: HandlerInput
+  req: express.Request
+  res: express.Response
+}) => Promise<HandlerOutput> | HandlerOutput
diff --git a/packages/pds/src/services/account/index.ts b/packages/pds/src/services/account/index.ts
index c0c57e17..1f7f429c 100644
--- a/packages/pds/src/services/account/index.ts
+++ b/packages/pds/src/services/account/index.ts
@@ -145,6 +145,14 @@ export class AccountService {
     await sequenceHandleUpdate(this.db, did, handle)
   }
 
+  async updateEmail(did: string, email: string) {
+    await this.db.db
+      .updateTable('user_account')
+      .set({ email: email.toLowerCase() })
+      .where('did', '=', did)
+      .executeTakeFirst()
+  }
+
   async updateUserPassword(did: string, password: string) {
     const passwordScrypt = await scrypt.hash(password)
     await this.db.db
diff --git a/packages/pds/tests/account.test.ts b/packages/pds/tests/account.test.ts
index d3b8f8f0..135b2582 100644
--- a/packages/pds/tests/account.test.ts
+++ b/packages/pds/tests/account.test.ts
@@ -168,6 +168,36 @@ describe('account', () => {
     ])
   })
 
+  it('allows administrative email updates', async () => {
+    await agent.api.com.atproto.admin.updateAccountEmail(
+      {
+        account: handle,
+        email: 'alIce-NEw@teST.com',
+      },
+      {
+        encoding: 'application/json',
+        headers: { authorization: util.adminAuth() },
+      },
+    )
+
+    const accnt = await ctx.services.account(ctx.db).getAccount(handle)
+    expect(accnt?.email).toBe('alice-new@test.com')
+
+    await agent.api.com.atproto.admin.updateAccountEmail(
+      {
+        account: did,
+        email,
+      },
+      {
+        encoding: 'application/json',
+        headers: { authorization: util.adminAuth() },
+      },
+    )
+
+    const accnt2 = await ctx.services.account(ctx.db).getAccount(handle)
+    expect(accnt2?.email).toBe(email)
+  })
+
   it('disallows duplicate email addresses and handles', async () => {
     const inviteCode = await createInviteCode(agent, 2)
     const email = 'bob@test.com'