From f69b50a83f3539963e3f8224700bb76fb05c4528 Mon Sep 17 00:00:00 2001
From: Mar0xy <marie@kaifa.ch>
Date: Wed, 18 Oct 2023 02:41:36 +0200
Subject: [PATCH] add: Require Approval for Signup

---
 .../migration/1697580470000-approvalSignup.js | 22 +++++++
 packages/backend/src/core/SignupService.ts    | 10 ++-
 .../src/core/entities/UserEntityService.ts    |  2 +
 packages/backend/src/models/Meta.ts           |  5 ++
 packages/backend/src/models/User.ts           | 10 +++
 packages/backend/src/models/UserPending.ts    |  5 ++
 .../backend/src/server/api/EndpointsModule.ts |  4 ++
 .../src/server/api/SigninApiService.ts        | 19 ++++++
 .../src/server/api/SignupApiService.ts        | 32 ++++++++++
 packages/backend/src/server/api/endpoints.ts  |  2 +
 .../api/endpoints/admin/approve-user.ts       | 61 +++++++++++++++++++
 .../src/server/api/endpoints/admin/meta.ts    |  5 ++
 .../server/api/endpoints/admin/show-user.ts   |  2 +
 .../server/api/endpoints/admin/update-meta.ts |  5 ++
 .../backend/src/server/api/endpoints/meta.ts  |  5 ++
 packages/backend/src/types.ts                 |  6 ++
 .../src/components/MkSignupDialog.form.vue    | 14 +++++
 .../src/components/MkSignupDialog.vue         |  5 +-
 .../src/components/MkVisitorDashboard.vue     |  3 +
 packages/frontend/src/pages/admin-user.vue    | 47 +++++++++++++-
 .../frontend/src/pages/admin/moderation.vue   |  7 +++
 .../src/pages/admin/modlog.ModLog.vue         |  6 +-
 packages/misskey-js/src/consts.ts             |  6 ++
 23 files changed, 278 insertions(+), 5 deletions(-)
 create mode 100644 packages/backend/migration/1697580470000-approvalSignup.js
 create mode 100644 packages/backend/src/server/api/endpoints/admin/approve-user.ts

diff --git a/packages/backend/migration/1697580470000-approvalSignup.js b/packages/backend/migration/1697580470000-approvalSignup.js
new file mode 100644
index 000000000000..c5f8255d49c0
--- /dev/null
+++ b/packages/backend/migration/1697580470000-approvalSignup.js
@@ -0,0 +1,22 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and other misskey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+export class ApprovalSignup1697580470000 {
+    name = 'ApprovalSignup1697580470000'
+
+    async up(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "meta" ADD "approvalRequiredForSignup" boolean DEFAULT false NOT NULL`);
+        await queryRunner.query(`ALTER TABLE "user" ADD "approved" boolean DEFAULT false NOT NULL`);
+        await queryRunner.query(`ALTER TABLE "user" ADD "signupReason" character varying(1000) NULL`);
+        await queryRunner.query(`ALTER TABLE "user_pending" ADD "reason" character varying(1000) NULL`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "approvalRequiredForSignup"`);
+        await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "approved"`);
+        await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "signupReason"`);
+        await queryRunner.query(`ALTER TABLE "user_pending" DROP COLUMN "reason"`);
+    }
+}
diff --git a/packages/backend/src/core/SignupService.ts b/packages/backend/src/core/SignupService.ts
index b9e3ded46f75..db29afe874d9 100644
--- a/packages/backend/src/core/SignupService.ts
+++ b/packages/backend/src/core/SignupService.ts
@@ -47,10 +47,12 @@ export class SignupService {
 		password?: string | null;
 		passwordHash?: MiUserProfile['password'] | null;
 		host?: string | null;
+		reason?: string | null;
 		ignorePreservedUsernames?: boolean;
 	}) {
-		const { username, password, passwordHash, host } = opts;
+		const { username, password, passwordHash, host, reason } = opts;
 		let hash = passwordHash;
+		const instance = await this.metaService.fetch(true);
 
 		// Validate username
 		if (!this.userEntityService.validateLocalUsername(username)) {
@@ -84,7 +86,6 @@ export class SignupService {
 		const isTheFirstUser = (await this.usersRepository.countBy({ host: IsNull() })) === 0;
 
 		if (!opts.ignorePreservedUsernames && !isTheFirstUser) {
-			const instance = await this.metaService.fetch(true);
 			const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase());
 			if (isPreserved) {
 				throw new Error('USED_USERNAME');
@@ -109,6 +110,9 @@ export class SignupService {
 			));
 
 		let account!: MiUser;
+		let defaultApproval = false;
+
+		if (!instance.approvalRequiredForSignup) defaultApproval = true;
 
 		// Start transaction
 		await this.db.transaction(async transactionalEntityManager => {
@@ -126,6 +130,8 @@ export class SignupService {
 				host: this.utilityService.toPunyNullable(host),
 				token: secret,
 				isRoot: isTheFirstUser,
+				approved: defaultApproval,
+				signupReason: reason,
 			}));
 
 			await transactionalEntityManager.save(new MiUserKeypair({
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index ef815a388a06..c954b3fdfaad 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -489,6 +489,8 @@ export class UserEntityService implements OnModuleInit {
 			...(opts.includeSecrets ? {
 				email: profile!.email,
 				emailVerified: profile!.emailVerified,
+				approved: user.approved,
+				signupReason: user.signupReason,
 				securityKeysList: profile!.twoFactorEnabled
 					? this.userSecurityKeysRepository.find({
 						where: {
diff --git a/packages/backend/src/models/Meta.ts b/packages/backend/src/models/Meta.ts
index 84ca762492ef..a3b061268d4f 100644
--- a/packages/backend/src/models/Meta.ts
+++ b/packages/backend/src/models/Meta.ts
@@ -174,6 +174,11 @@ export class MiMeta {
 	})
 	public emailRequiredForSignup: boolean;
 
+	@Column('boolean', {
+		default: false,
+	})
+	public approvalRequiredForSignup: boolean;
+
 	@Column('boolean', {
 		default: false,
 	})
diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts
index 219497a125fc..52856182d263 100644
--- a/packages/backend/src/models/User.ts
+++ b/packages/backend/src/models/User.ts
@@ -254,6 +254,16 @@ export class MiUser {
 	})
 	public token: string | null;
 
+	@Column('boolean', {
+		default: false,
+	})
+	public approved: boolean;
+
+	@Column('varchar', {
+		length: 1000, nullable: true,
+	})
+	public signupReason: string | null;
+
 	constructor(data: Partial<MiUser>) {
 		if (data == null) return;
 
diff --git a/packages/backend/src/models/UserPending.ts b/packages/backend/src/models/UserPending.ts
index 8b1f8f617fea..6b26bd228c55 100644
--- a/packages/backend/src/models/UserPending.ts
+++ b/packages/backend/src/models/UserPending.ts
@@ -31,4 +31,9 @@ export class MiUserPending {
 		length: 128,
 	})
 	public password: string;
+
+	@Column('varchar', {
+		length: 1000,
+	})
+	public reason: string;
 }
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index 86a64d71219a..e4fef3218c69 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -69,6 +69,7 @@ import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderatio
 import * as ep___admin_showUser from './endpoints/admin/show-user.js';
 import * as ep___admin_showUsers from './endpoints/admin/show-users.js';
 import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js';
+import * as ep___admin_approveUser from './endpoints/admin/approve-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_deleteAccount from './endpoints/admin/delete-account.js';
@@ -430,6 +431,7 @@ const $admin_showModerationLogs: Provider = { provide: 'ep:admin/show-moderation
 const $admin_showUser: Provider = { provide: 'ep:admin/show-user', useClass: ep___admin_showUser.default };
 const $admin_showUsers: Provider = { provide: 'ep:admin/show-users', useClass: ep___admin_showUsers.default };
 const $admin_suspendUser: Provider = { provide: 'ep:admin/suspend-user', useClass: ep___admin_suspendUser.default };
+const $admin_approveUser: Provider = { provide: 'ep:admin/approve-user', useClass: ep___admin_approveUser.default };
 const $admin_unsuspendUser: Provider = { provide: 'ep:admin/unsuspend-user', useClass: ep___admin_unsuspendUser.default };
 const $admin_updateMeta: Provider = { provide: 'ep:admin/update-meta', useClass: ep___admin_updateMeta.default };
 const $admin_deleteAccount: Provider = { provide: 'ep:admin/delete-account', useClass: ep___admin_deleteAccount.default };
@@ -795,6 +797,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$admin_showUser,
 		$admin_showUsers,
 		$admin_suspendUser,
+		$admin_approveUser,
 		$admin_unsuspendUser,
 		$admin_updateMeta,
 		$admin_deleteAccount,
@@ -1154,6 +1157,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$admin_showUser,
 		$admin_showUsers,
 		$admin_suspendUser,
+		$admin_approveUser,
 		$admin_unsuspendUser,
 		$admin_updateMeta,
 		$admin_deleteAccount,
diff --git a/packages/backend/src/server/api/SigninApiService.ts b/packages/backend/src/server/api/SigninApiService.ts
index f3115690fe5e..ec4dd4ecc949 100644
--- a/packages/backend/src/server/api/SigninApiService.ts
+++ b/packages/backend/src/server/api/SigninApiService.ts
@@ -20,6 +20,7 @@ import { IdService } from '@/core/IdService.js';
 import { bindThis } from '@/decorators.js';
 import { WebAuthnService } from '@/core/WebAuthnService.js';
 import { UserAuthService } from '@/core/UserAuthService.js';
+import { MetaService } from '@/core/MetaService.js';
 import { RateLimiterService } from './RateLimiterService.js';
 import { SigninService } from './SigninService.js';
 import type { AuthenticationResponseJSON } from '@simplewebauthn/typescript-types';
@@ -45,6 +46,7 @@ export class SigninApiService {
 		private signinService: SigninService,
 		private userAuthService: UserAuthService,
 		private webAuthnService: WebAuthnService,
+		private metaService: MetaService,
 	) {
 	}
 
@@ -63,6 +65,8 @@ export class SigninApiService {
 		reply.header('Access-Control-Allow-Origin', this.config.url);
 		reply.header('Access-Control-Allow-Credentials', 'true');
 
+		const instance = await this.metaService.fetch(true);
+
 		const body = request.body;
 		const username = body['username'];
 		const password = body['password'];
@@ -122,6 +126,17 @@ export class SigninApiService {
 
 		const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
 
+		if (!user.approved && instance.approvalRequiredForSignup) {
+			reply.code(403);
+			return {
+				error: {
+					message: 'The account has not been approved by an admin yet. Try again later.',
+					code: 'NOT_APPROVED',
+					id: '22d05606-fbcf-421a-a2db-b32241faft1b',
+				},
+			};
+		}
+
 		// Compare password
 		const same = await bcrypt.compare(password, profile.password!);
 
@@ -140,6 +155,7 @@ export class SigninApiService {
 
 		if (!profile.twoFactorEnabled) {
 			if (same) {
+				if (!instance.approvalRequiredForSignup && !user.approved) this.usersRepository.update(user.id, { approved: true });
 				return this.signinService.signin(request, reply, user);
 			} else {
 				return await fail(403, {
@@ -163,6 +179,8 @@ export class SigninApiService {
 				});
 			}
 
+			if (!instance.approvalRequiredForSignup && !user.approved) this.usersRepository.update(user.id, { approved: true });
+
 			return this.signinService.signin(request, reply, user);
 		} else if (body.credential) {
 			if (!same && !profile.usePasswordLessLogin) {
@@ -174,6 +192,7 @@ export class SigninApiService {
 			const authorized = await this.webAuthnService.verifyAuthentication(user.id, body.credential);
 
 			if (authorized) {
+				if (!instance.approvalRequiredForSignup && !user.approved) this.usersRepository.update(user.id, { approved: true });
 				return this.signinService.signin(request, reply, user);
 			} else {
 				return await fail(403, {
diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts
index 753984ef527c..14f132acbd35 100644
--- a/packages/backend/src/server/api/SignupApiService.ts
+++ b/packages/backend/src/server/api/SignupApiService.ts
@@ -21,6 +21,7 @@ import { bindThis } from '@/decorators.js';
 import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js';
 import { SigninService } from './SigninService.js';
 import type { FastifyRequest, FastifyReply } from 'fastify';
+import instance from './endpoints/charts/instance.js';
 
 @Injectable()
 export class SignupApiService {
@@ -62,6 +63,7 @@ export class SignupApiService {
 				host?: string;
 				invitationCode?: string;
 				emailAddress?: string;
+				reason?: string;
 				'hcaptcha-response'?: string;
 				'g-recaptcha-response'?: string;
 				'turnstile-response'?: string;
@@ -99,6 +101,7 @@ export class SignupApiService {
 		const password = body['password'];
 		const host: string | null = process.env.NODE_ENV === 'test' ? (body['host'] ?? null) : null;
 		const invitationCode = body['invitationCode'];
+		const reason = body['reason'];
 		const emailAddress = body['emailAddress'];
 
 		if (instance.emailRequiredForSignup) {
@@ -114,6 +117,13 @@ export class SignupApiService {
 			}
 		}
 
+		if (instance.approvalRequiredForSignup) {
+			if (reason == null || typeof reason !== 'string') {
+				reply.code(400);
+				return;
+			}
+		}
+
 		let ticket: MiRegistrationTicket | null = null;
 
 		if (instance.disableRegistration) {
@@ -182,6 +192,7 @@ export class SignupApiService {
 				email: emailAddress!,
 				username: username,
 				password: hash,
+				reason: reason,
 			}).then(x => this.userPendingsRepository.findOneByOrFail(x.identifiers[0]));
 
 			const link = `${this.config.url}/signup-complete/${code}`;
@@ -197,6 +208,19 @@ export class SignupApiService {
 				});
 			}
 
+			reply.code(204);
+			return;
+		} else if (instance.approvalRequiredForSignup) {
+			await this.signupService.signup({
+				username, password, host, reason,
+			});
+
+			if (emailAddress) {
+				this.emailService.sendEmail(emailAddress, 'Approval pending',
+					'Congratulations! Your account is now pending approval. You will get notified when you have been accepted.',
+					'Congratulations! Your account is now pending approval. You will get notified when you have been accepted.');
+			}
+
 			reply.code(204);
 			return;
 		} else {
@@ -234,6 +258,8 @@ export class SignupApiService {
 
 		const code = body['code'];
 
+		const instance = await this.metaService.fetch(true);
+
 		try {
 			const pendingUser = await this.userPendingsRepository.findOneByOrFail({ code });
 
@@ -244,6 +270,7 @@ export class SignupApiService {
 			const { account, secret } = await this.signupService.signup({
 				username: pendingUser.username,
 				passwordHash: pendingUser.password,
+				reason: pendingUser.reason,
 			});
 
 			this.userPendingsRepository.delete({
@@ -266,6 +293,11 @@ export class SignupApiService {
 					pendingUserId: null,
 				});
 			}
+			
+			if (instance.approvalRequiredForSignup) {
+				reply.code(204);
+				return;
+			}
 
 			return this.signinService.signin(request, reply, account as MiLocalUser);
 		} catch (err) {
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 41232091c614..898bf083fdcb 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -70,6 +70,7 @@ import * as ep___admin_showModerationLogs from './endpoints/admin/show-moderatio
 import * as ep___admin_showUser from './endpoints/admin/show-user.js';
 import * as ep___admin_showUsers from './endpoints/admin/show-users.js';
 import * as ep___admin_suspendUser from './endpoints/admin/suspend-user.js';
+import * as ep___admin_approveUser from './endpoints/admin/approve-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_deleteAccount from './endpoints/admin/delete-account.js';
@@ -429,6 +430,7 @@ const eps = [
 	['admin/show-user', ep___admin_showUser],
 	['admin/show-users', ep___admin_showUsers],
 	['admin/suspend-user', ep___admin_suspendUser],
+	['admin/approve-user', ep___admin_approveUser],
 	['admin/unsuspend-user', ep___admin_unsuspendUser],
 	['admin/update-meta', ep___admin_updateMeta],
 	['admin/delete-account', ep___admin_deleteAccount],
diff --git a/packages/backend/src/server/api/endpoints/admin/approve-user.ts b/packages/backend/src/server/api/endpoints/admin/approve-user.ts
new file mode 100644
index 000000000000..0ea656ddaf87
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/approve-user.ts
@@ -0,0 +1,61 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { UserProfilesRepository, UsersRepository } from '@/models/_.js';
+import { ModerationLogService } from '@/core/ModerationLogService.js';
+import { DI } from '@/di-symbols.js';
+import { EmailService } from '@/core/EmailService.js';
+
+export const meta = {
+	tags: ['admin'],
+
+	requireCredential: true,
+	requireModerator: true,
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		userId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['userId'],
+} as const;
+
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
+	constructor(
+		@Inject(DI.usersRepository)
+		private usersRepository: UsersRepository,
+
+        @Inject(DI.userProfilesRepository)
+		private userProfilesRepository: UserProfilesRepository,
+
+		private moderationLogService: ModerationLogService,
+        private emailService: EmailService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const user = await this.usersRepository.findOneBy({ id: ps.userId });
+
+			if (user == null) {
+				throw new Error('user not found');
+			}
+
+			const profile = await this.userProfilesRepository.findOneBy({ userId: ps.userId });
+
+			await this.usersRepository.update(user.id, {
+				approved: true,
+			});
+
+			if (profile?.email) {
+				this.emailService.sendEmail(profile.email, 'Account Approved',
+					'Your Account has been approved have fun socializing!',
+					'Your Account has been approved have fun socializing!');
+			}
+
+			this.moderationLogService.log(me, 'approve', {
+				userId: user.id,
+				userUsername: user.username,
+				userHost: user.host,
+			});
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts
index febc4ab1b172..6306175c5e17 100644
--- a/packages/backend/src/server/api/endpoints/admin/meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/meta.ts
@@ -33,6 +33,10 @@ export const meta = {
 				type: 'boolean',
 				optional: false, nullable: false,
 			},
+			approvalRequiredForSignup: {
+				type: 'boolean',
+				optional: false, nullable: false,
+			},
 			enableHcaptcha: {
 				type: 'boolean',
 				optional: false, nullable: false,
@@ -454,6 +458,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				privacyPolicyUrl: instance.privacyPolicyUrl,
 				disableRegistration: instance.disableRegistration,
 				emailRequiredForSignup: instance.emailRequiredForSignup,
+				approvalRequiredForSignup: instance.approvalRequiredForSignup,
 				enableHcaptcha: instance.enableHcaptcha,
 				hcaptchaSiteKey: instance.hcaptchaSiteKey,
 				enableRecaptcha: instance.enableRecaptcha,
diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts
index 2b50354cef18..03127a47bbdb 100644
--- a/packages/backend/src/server/api/endpoints/admin/show-user.ts
+++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts
@@ -74,6 +74,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			return {
 				email: profile.email,
 				emailVerified: profile.emailVerified,
+				approved: user.approved,
+				signupReason: user.signupReason,
 				autoAcceptFollowed: profile.autoAcceptFollowed,
 				noCrawle: profile.noCrawle,
 				preventAiLearning: profile.preventAiLearning,
diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
index 5a215696fb11..7a169d645390 100644
--- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
@@ -60,6 +60,7 @@ export const paramDef = {
 		cacheRemoteFiles: { type: 'boolean' },
 		cacheRemoteSensitiveFiles: { type: 'boolean' },
 		emailRequiredForSignup: { type: 'boolean' },
+		approvalRequiredForSignup: { type: 'boolean' },
 		enableHcaptcha: { type: 'boolean' },
 		hcaptchaSiteKey: { type: 'string', nullable: true },
 		hcaptchaSecretKey: { type: 'string', nullable: true },
@@ -254,6 +255,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				set.emailRequiredForSignup = ps.emailRequiredForSignup;
 			}
 
+			if (ps.approvalRequiredForSignup !== undefined) {
+				set.approvalRequiredForSignup = ps.approvalRequiredForSignup;
+			}
+
 			if (ps.enableHcaptcha !== undefined) {
 				set.enableHcaptcha = ps.enableHcaptcha;
 			}
diff --git a/packages/backend/src/server/api/endpoints/meta.ts b/packages/backend/src/server/api/endpoints/meta.ts
index f7c2962bc2ff..edfc167fb119 100644
--- a/packages/backend/src/server/api/endpoints/meta.ts
+++ b/packages/backend/src/server/api/endpoints/meta.ts
@@ -100,6 +100,10 @@ export const meta = {
 				type: 'boolean',
 				optional: false, nullable: false,
 			},
+			approvalRequiredForSignup: {
+				type: 'boolean',
+				optional: false, nullable: false,
+			},
 			enableHcaptcha: {
 				type: 'boolean',
 				optional: false, nullable: false,
@@ -349,6 +353,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 				privacyPolicyUrl: instance.privacyPolicyUrl,
 				disableRegistration: instance.disableRegistration,
 				emailRequiredForSignup: instance.emailRequiredForSignup,
+				approvalRequiredForSignup: instance.approvalRequiredForSignup,
 				enableHcaptcha: instance.enableHcaptcha,
 				hcaptchaSiteKey: instance.hcaptchaSiteKey,
 				enableRecaptcha: instance.enableRecaptcha,
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index 361a4931eb0a..e55952f29661 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -46,6 +46,7 @@ export const followersVisibilities = ['public', 'followers', 'private'] as const
 export const moderationLogTypes = [
 	'updateServerSettings',
 	'suspend',
+	'approve',
 	'unsuspend',
 	'updateUserNote',
 	'addCustomEmoji',
@@ -93,6 +94,11 @@ export type ModerationLogPayloads = {
 		userUsername: string;
 		userHost: string | null;
 	};
+	approve: {
+		userId: string;
+		userUsername: string;
+		userHost: string | null;
+	};
 	unsuspend: {
 		userId: string;
 		userUsername: string;
diff --git a/packages/frontend/src/components/MkSignupDialog.form.vue b/packages/frontend/src/components/MkSignupDialog.form.vue
index 44cfb6f0fa5c..feff73d510c0 100644
--- a/packages/frontend/src/components/MkSignupDialog.form.vue
+++ b/packages/frontend/src/components/MkSignupDialog.form.vue
@@ -62,6 +62,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 					<span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.passwordNotMatched }}</span>
 				</template>
 			</MkInput>
+			<MkInput v-if="instance.approvalRequiredForSignup" v-model="reason" type="text" :spellcheck="false" required data-cy-signup-reason>
+				<template #label>Reason <div v-tooltip:dialog="i18n.ts._signup.emailAddressInfo" class="_button _help"><i class="ph-question ph-bold ph-lg"></i></div></template>
+				<template #prefix><i class="ph-envelope ph-bold ph-lg"></i></template>
+			</MkInput>
 			<MkCaptcha v-if="instance.enableHcaptcha" ref="hcaptcha" v-model="hCaptchaResponse" :class="$style.captcha" provider="hcaptcha" :sitekey="instance.hcaptchaSiteKey"/>
 			<MkCaptcha v-if="instance.enableRecaptcha" ref="recaptcha" v-model="reCaptchaResponse" :class="$style.captcha" provider="recaptcha" :sitekey="instance.recaptchaSiteKey"/>
 			<MkCaptcha v-if="instance.enableTurnstile" ref="turnstile" v-model="turnstileResponse" :class="$style.captcha" provider="turnstile" :sitekey="instance.turnstileSiteKey"/>
@@ -97,6 +101,7 @@ const props = withDefaults(defineProps<{
 const emit = defineEmits<{
 	(ev: 'signup', user: Record<string, any>): void;
 	(ev: 'signupEmailPending'): void;
+	(ev: 'approvalPending'): void;
 }>();
 
 const host = toUnicode(config.host);
@@ -109,6 +114,7 @@ const username = ref<string>('');
 const password = ref<string>('');
 const retypedPassword = ref<string>('');
 const invitationCode = ref<string>('');
+const reason = ref<string>('');
 const email = ref('');
 const usernameState = ref<null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range'>(null);
 const emailState = ref<null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:banned' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error'>(null);
@@ -250,6 +256,7 @@ async function onSubmit(): Promise<void> {
 			password: password.value,
 			emailAddress: email.value,
 			invitationCode: invitationCode.value,
+			reason: reason.value,
 			'hcaptcha-response': hCaptchaResponse.value,
 			'g-recaptcha-response': reCaptchaResponse.value,
 			'turnstile-response': turnstileResponse.value,
@@ -261,6 +268,13 @@ async function onSubmit(): Promise<void> {
 				text: i18n.t('_signup.emailSent', { email: email.value }),
 			});
 			emit('signupEmailPending');
+		} else if (instance.approvalRequiredForSignup) {
+			os.alert({
+				type: 'success',
+				title: i18n.ts._signup.almostThere,
+				text: i18n.t('_signup.emailSent', { email }),
+			});
+			emit('approvalPending');
 		} else {
 			const res = await os.api('signin', {
 				username: username.value,
diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue
index b4fba114a6cf..f9dae003c68d 100644
--- a/packages/frontend/src/components/MkSignupDialog.vue
+++ b/packages/frontend/src/components/MkSignupDialog.vue
@@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<XServerRules @done="isAcceptedServerRule = true" @cancel="dialog.close()"/>
 			</template>
 			<template v-else>
-				<XSignup :autoSet="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending"/>
+				<XSignup :autoSet="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending" @approvalPending="onApprovalPending"/>
 			</template>
 		</Transition>
 	</div>
@@ -63,6 +63,9 @@ function onSignup(res) {
 function onSignupEmailPending() {
 	dialog.value.close();
 }
+function onApprovalPending() {
+	dialog.close();
+}
 </script>
 
 <style lang="scss" module>
diff --git a/packages/frontend/src/components/MkVisitorDashboard.vue b/packages/frontend/src/components/MkVisitorDashboard.vue
index 9ed08ee372a4..7e708a0ca47e 100644
--- a/packages/frontend/src/components/MkVisitorDashboard.vue
+++ b/packages/frontend/src/components/MkVisitorDashboard.vue
@@ -21,6 +21,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 			<div v-if="instance.disableRegistration" :class="$style.mainWarn">
 				<MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
 			</div>
+			<div v-if="instance.approvalRequiredForSignup" :class="$style.mainWarn">
+				<MkInfo warn>This instance is only accepting users who specify a reason for registration.<br />You must enter a reason during sign up as to why you want to join this instance.</MkInfo>
+			</div>
 			<div class="_gaps_s" :class="$style.mainActions">
 				<MkButton :class="$style.mainAction" full rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.joinThisServer }}</MkButton>
 				<MkButton :class="$style.mainAction" full rounded @click="exploreOtherServers()">{{ i18n.ts.exploreOtherServers }}</MkButton>
diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue
index d69d627ce823..a4d45624116d 100644
--- a/packages/frontend/src/pages/admin-user.vue
+++ b/packages/frontend/src/pages/admin-user.vue
@@ -15,6 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
 						<span class="name"><MkUserName class="name" :user="user"/></span>
 						<span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span>
 						<span class="state">
+							<span v-if="!approved" class="silenced">Not Approved</span>
 							<span v-if="suspended" class="suspended">Suspended</span>
 							<span v-if="silenced" class="silenced">Silenced</span>
 							<span v-if="moderator" class="moderator">Moderator</span>
@@ -197,6 +198,20 @@ SPDX-License-Identifier: AGPL-3.0-only
 				<MkObjectView tall :value="user">
 				</MkObjectView>
 			</div>
+
+			<div v-else-if="tab === 'approval'" class="_gaps_m">
+				<MkKeyValue oneline>
+					<template #key>Approval Status</template>
+					<template #value><span class="_monospace">{{ approved ? 'Approved' : 'Not Approved' }}</span></template>
+				</MkKeyValue>
+
+				<MkTextarea v-model="signupReason" readonly>
+					<template #label>Reason</template>
+				</MkTextarea>
+
+				<MkButton v-if="$i.isAdmin" inline success @click="approveAccount">Approve</MkButton>
+				<MkButton v-if="$i.isAdmin" inline danger @click="deleteAccount">Deny & Delete</MkButton>
+			</div>
 		</FormSuspense>
 	</MkSpacer>
 </MkStickyContainer>
@@ -243,8 +258,10 @@ const ips = ref<Misskey.entities.AdminGetUserIpsResponse | null>(null);
 const ap = ref<any>(null);
 const moderator = ref(false);
 const silenced = ref(false);
+const approved = ref(false);
 const suspended = ref(false);
 const moderationNote = ref('');
+const signupReason = ref('');
 const filesPagination = {
 	endpoint: 'admin/drive/files' as const,
 	limit: 10,
@@ -274,8 +291,10 @@ function createFetcher() {
 		ips.value = _ips;
 		moderator.value = info.value.isModerator;
 		silenced.value = info.value.isSilenced;
+		approved.value = info.value.approved;
 		suspended.value = info.value.isSuspended;
 		moderationNote.value = info.value.moderationNote;
+		signupReason.value = info.signupReason;
 
 		watch(moderationNote, async () => {
 			await os.api('admin/update-user-note', { userId: user.value.id, text: moderationNote.value });
@@ -405,6 +424,16 @@ async function deleteAccount() {
 	}
 }
 
+async function approveAccount() {
+	const confirm = await os.confirm({
+		type: 'warning',
+		text: i18n.ts.suspendConfirm,
+	});
+	if (confirm.canceled) return;
+	await os.api('admin/approve-user', { userId: user.id });
+	await refreshUser();
+}
+
 async function assignRole() {
 	const roles = await os.api('admin/roles/list');
 
@@ -515,7 +544,11 @@ const headerTabs = computed(() => [{
 	key: 'raw',
 	title: 'Raw',
 	icon: 'ti ti-code',
-}]);
+}, (iAmAdmin && !approved) ? {
+	key: 'approval',
+	title: 'Approval',
+	icon: 'ti ti-eye',
+} : undefined]);
 
 definePageMetadata(computed(() => ({
 	title: user.value ? acct(user.value) : i18n.ts.userInfo,
@@ -606,6 +639,18 @@ definePageMetadata(computed(() => ({
 		}
 	}
 }
+
+.casdwq {
+	.silenced {
+		color: var(--warn);
+		border-color: var(--warn);
+	}
+
+	.moderator {
+		color: var(--success);
+		border-color: var(--success);
+	}
+}
 </style>
 
 <style lang="scss" module>
diff --git a/packages/frontend/src/pages/admin/moderation.vue b/packages/frontend/src/pages/admin/moderation.vue
index f6c0b29403e7..abcaa66654ca 100644
--- a/packages/frontend/src/pages/admin/moderation.vue
+++ b/packages/frontend/src/pages/admin/moderation.vue
@@ -18,6 +18,10 @@ SPDX-License-Identifier: AGPL-3.0-only
 						<template #label>{{ i18n.ts.emailRequiredForSignup }}</template>
 					</MkSwitch>
 
+					<MkSwitch v-model="approvalRequiredForSignup">
+						<template #label>Require approval for new sign-ups</template>
+					</MkSwitch>
+
 					<FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink>
 
 					<MkInput v-model="tosUrl" type="url">
@@ -74,6 +78,7 @@ import FormLink from '@/components/form/link.vue';
 
 const enableRegistration = ref<boolean>(false);
 const emailRequiredForSignup = ref<boolean>(false);
+const approvalRequiredForSignup = ref<boolean>(false);
 const sensitiveWords = ref<string>('');
 const hiddenTags = ref<string>('');
 const preservedUsernames = ref<string>('');
@@ -84,6 +89,7 @@ async function init() {
 	const meta = await os.api('admin/meta');
 	enableRegistration.value = !meta.disableRegistration;
 	emailRequiredForSignup.value = meta.emailRequiredForSignup;
+	approvalRequiredForSignup.value = meta.approvalRequiredForSignup;
 	sensitiveWords.value = meta.sensitiveWords.join('\n');
 	hiddenTags.value = meta.hiddenTags.join('\n');
 	preservedUsernames.value = meta.preservedUsernames.join('\n');
@@ -95,6 +101,7 @@ function save() {
 	os.apiWithDialog('admin/update-meta', {
 		disableRegistration: !enableRegistration.value,
 		emailRequiredForSignup: emailRequiredForSignup.value,
+		approvalRequiredForSignup: approvalRequiredForSignup.value,
 		tosUrl: tosUrl.value,
 		privacyPolicyUrl: privacyPolicyUrl.value,
 		sensitiveWords: sensitiveWords.value.split('\n'),
diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue
index 524c35a9439d..03cf8b5d83de 100644
--- a/packages/frontend/src/pages/admin/modlog.ModLog.vue
+++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue
@@ -10,11 +10,12 @@ SPDX-License-Identifier: AGPL-3.0-only
 			:class="{
 				[$style.logGreen]: ['createRole', 'addCustomEmoji', 'createGlobalAnnouncement', 'createUserAnnouncement', 'createAd', 'createInvitation', 'createAvatarDecoration'].includes(log.type),
 				[$style.logYellow]: ['markSensitiveDriveFile', 'resetPassword'].includes(log.type),
-				[$style.logRed]: ['suspend', 'deleteRole', 'suspendRemoteInstance', 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'deleteCustomEmoji', 'deleteNote', 'deleteDriveFile', 'deleteAd', 'deleteAvatarDecoration'].includes(log.type)
+				[$style.logRed]: ['suspend', 'approve', 'deleteRole', 'suspendRemoteInstance', 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'deleteCustomEmoji', 'deleteNote', 'deleteDriveFile', 'deleteAd', 'deleteAvatarDecoration'].includes(log.type)
 			}"
 		>{{ i18n.ts._moderationLogTypes[log.type] }}</b>
 		<span v-if="log.type === 'updateUserNote'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
 		<span v-else-if="log.type === 'suspend'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
+		<span v-else-if="log.type === 'approve'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
 		<span v-else-if="log.type === 'unsuspend'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
 		<span v-else-if="log.type === 'resetPassword'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
 		<span v-else-if="log.type === 'assignRole'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }} <i class="ti ti-arrow-right"></i> {{ log.info.roleName }}</span>
@@ -68,6 +69,9 @@ SPDX-License-Identifier: AGPL-3.0-only
 		<template v-else-if="log.type === 'suspend'">
 			<div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.userId}`" class="_link">@{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</MkA></div>
 		</template>
+		<template v-else-if="log.type === 'approve'">
+			<div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.userId}`" class="_link">@{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</MkA></div>
+		</template>
 		<template v-else-if="log.type === 'unsuspend'">
 			<div>{{ i18n.ts.user }}: <MkA :to="`/admin/user/${log.info.userId}`" class="_link">@{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</MkA></div>
 		</template>
diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts
index 0e446c121591..9b25e2af74ab 100644
--- a/packages/misskey-js/src/consts.ts
+++ b/packages/misskey-js/src/consts.ts
@@ -98,6 +98,7 @@ export const permissions = [
 export const moderationLogTypes = [
 	'updateServerSettings',
 	'suspend',
+	'approve',
 	'unsuspend',
 	'updateUserNote',
 	'addCustomEmoji',
@@ -145,6 +146,11 @@ export type ModerationLogPayloads = {
 		userUsername: string;
 		userHost: string | null;
 	};
+	approve: {
+		userId: string;
+		userUsername: string;
+		userHost: string | null;
+	};
 	unsuspend: {
 		userId: string;
 		userUsername: string;