From 329f055a976dc3b2e12f2a0141bfab2c57ae9193 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Mon, 27 Jun 2022 23:49:16 +0900
Subject: [PATCH] feat: make possible to delete an account by admin

Resolve #8830
---
 CHANGELOG.md                                  |  1 +
 locales/ja-JP.yml                             |  2 ++
 packages/backend/src/server/api/endpoints.ts  |  2 ++
 .../api/endpoints/admin/delete-account.ts     | 31 +++++++++++++++++++
 .../server/api/endpoints/i/delete-account.ts  | 20 ++----------
 .../backend/src/services/delete-account.ts    | 23 ++++++++++++++
 packages/client/src/pages/user-info.vue       | 29 ++++++++++++++++-
 7 files changed, 90 insertions(+), 18 deletions(-)
 create mode 100644 packages/backend/src/server/api/endpoints/admin/delete-account.ts
 create mode 100644 packages/backend/src/services/delete-account.ts

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8b147ed4b6..e9bb600ff9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -16,6 +16,7 @@ You should also include the user name that made the change.
 - Server: Add rate limit to i/notifications @tamaina
 - Client: Improve control panel @syuilo
 - Client: Show warning in control panel when there is an unresolved abuse report @syuilo
+- Make possible to delete an account by admin @syuilo
 - Improve player detection in URL preview @mei23
 - Add Badge Image to Push Notification #8012 @tamaina
 - Client: Removing entries from a clip @futchitwo
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 7788a04dc3..f813389225 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -855,6 +855,8 @@ thereIsUnresolvedAbuseReportWarning: "未対応の通報があります。"
 recommended: "推奨"
 check: "チェック"
 isSystemAccount: "システムにより自動で作成・管理されているアカウントです。"
+typeToConfirm: "この操作を行うには {x} と入力してください"
+deleteAccount: "アカウント削除"
 
 _emailUnavailable:
   used: "既に使用されています"
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 11d9d7c026..93f93cef0c 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -59,6 +59,7 @@ import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-user.js';
 import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js';
 import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js';
 import * as ep___admin_vacuum from './endpoints/admin/vacuum.js';
+import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js';
 import * as ep___announcements from './endpoints/announcements.js';
 import * as ep___antennas_create from './endpoints/antennas/create.js';
 import * as ep___antennas_delete from './endpoints/antennas/delete.js';
@@ -370,6 +371,7 @@ const eps = [
 	['admin/unsuspend-user', ep___admin_unsuspendUser],
 	['admin/update-meta', ep___admin_updateMeta],
 	['admin/vacuum', ep___admin_vacuum],
+	['admin/delete-account', ep___admin_deleteAccount],
 	['announcements', ep___announcements],
 	['antennas/create', ep___antennas_create],
 	['antennas/delete', ep___antennas_delete],
diff --git a/packages/backend/src/server/api/endpoints/admin/delete-account.ts b/packages/backend/src/server/api/endpoints/admin/delete-account.ts
new file mode 100644
index 0000000000..2d7ef2f236
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/delete-account.ts
@@ -0,0 +1,31 @@
+import { Users } from '@/models/index.js';
+import { deleteAccount } from '@/services/delete-account.js';
+import define from '../../define.js';
+
+export const meta = {
+	tags: ['admin'],
+
+	requireCredential: true,
+	requireAdmin: true,
+
+	res: {
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		userId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['userId'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+export default define(meta, paramDef, async (ps) => {
+	const user = await Users.findOneByOrFail({ id: ps.userId });
+	if (user.isDeleted) {
+		return;
+	}
+
+	await deleteAccount(user);
+});
diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts
index 184005eb53..ede4a9d03b 100644
--- a/packages/backend/src/server/api/endpoints/i/delete-account.ts
+++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts
@@ -1,9 +1,7 @@
 import bcrypt from 'bcryptjs';
-import define from '../../define.js';
 import { UserProfiles, Users } from '@/models/index.js';
-import { doPostSuspend } from '@/services/suspend-user.js';
-import { publishUserEvent } from '@/services/stream.js';
-import { createDeleteAccountJob } from '@/queue/index.js';
+import { deleteAccount } from '@/services/delete-account.js';
+import define from '../../define.js';
 
 export const meta = {
 	requireCredential: true,
@@ -34,17 +32,5 @@ export default define(meta, paramDef, async (ps, user) => {
 		throw new Error('incorrect password');
 	}
 
-	// 物理削除する前にDelete activityを送信する
-	await doPostSuspend(user).catch(e => {});
-
-	createDeleteAccountJob(user, {
-		soft: false,
-	});
-
-	await Users.update(user.id, {
-		isDeleted: true,
-	});
-
-	// Terminate streaming
-	publishUserEvent(user.id, 'terminate', {});
+	await deleteAccount(user);
 });
diff --git a/packages/backend/src/services/delete-account.ts b/packages/backend/src/services/delete-account.ts
new file mode 100644
index 0000000000..0fdceb671b
--- /dev/null
+++ b/packages/backend/src/services/delete-account.ts
@@ -0,0 +1,23 @@
+import { Users } from '@/models/index.js';
+import { createDeleteAccountJob } from '@/queue/index.js';
+import { publishUserEvent } from './stream.js';
+import { doPostSuspend } from './suspend-user.js';
+
+export async function deleteAccount(user: {
+	id: string;
+	host: string | null;
+}): Promise<void> {
+	// 物理削除する前にDelete activityを送信する
+	await doPostSuspend(user).catch(e => {});
+
+	createDeleteAccountJob(user, {
+		soft: false,
+	});
+
+	await Users.update(user.id, {
+		isDeleted: true,
+	});
+
+	// Terminate streaming
+	publishUserEvent(user.id, 'terminate', {});
+}
diff --git a/packages/client/src/pages/user-info.vue b/packages/client/src/pages/user-info.vue
index 86c1be8d06..9dfb2d87a0 100644
--- a/packages/client/src/pages/user-info.vue
+++ b/packages/client/src/pages/user-info.vue
@@ -35,7 +35,10 @@
 					<FormSwitch v-model="silenced" class="_formBlock" @update:modelValue="toggleSilence">{{ $ts.silence }}</FormSwitch>
 					<FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.suspend }}</FormSwitch>
 					{{ $ts.reflectMayTakeTime }}
-					<FormButton v-if="user.host == null && iAmModerator" class="_formBlock" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton>
+					<div class="_formBlock">
+						<FormButton v-if="user.host == null && iAmModerator" inline style="margin-right: 8px;" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton>
+						<FormButton v-if="$i.isAdmin" inline danger @click="deleteAccount">{{ $ts.deleteAccount }}</FormButton>
+					</div>
 				</FormSection>
 
 				<FormSection>
@@ -233,6 +236,30 @@ async function deleteAllFiles() {
 	await refreshUser();
 }
 
+async function deleteAccount() {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.deleteAccountConfirm,
+	});
+	if (confirm.canceled) return;
+
+	const typed = await os.inputText({
+		text: i18n.t('typeToConfirm', { x: user?.username }),
+	});
+	if (typed.canceled) return;
+
+	if (typed.result === user?.username) {
+		await os.apiWithDialog('admin/delete-account', {
+			userId: user.id,
+		});
+	} else {
+		os.alert({
+			type: 'error',
+			text: 'input not match',
+		});
+	}
+}
+
 watch(() => props.userId, () => {
 	init = createFetcher();
 }, {