summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMar0xy <marie@kaifa.ch>2023-10-18 02:41:36 +0200
committerMar0xy <marie@kaifa.ch>2023-10-18 02:41:36 +0200
commit2f2d88dcfc76bac6815d60fa9915b8e797853292 (patch)
treefa3e903e9444ee4b92d91a6ff58264a4387d7523
parentchore: change some misskey references to sharkey (diff)
downloadsharkey-2f2d88dcfc76bac6815d60fa9915b8e797853292.tar.gz
sharkey-2f2d88dcfc76bac6815d60fa9915b8e797853292.tar.bz2
sharkey-2f2d88dcfc76bac6815d60fa9915b8e797853292.zip
add: Require Approval for Signup
-rw-r--r--packages/backend/migration/1697580470000-approvalSignup.js22
-rw-r--r--packages/backend/src/core/SignupService.ts10
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts2
-rw-r--r--packages/backend/src/models/Meta.ts5
-rw-r--r--packages/backend/src/models/User.ts10
-rw-r--r--packages/backend/src/models/UserPending.ts5
-rw-r--r--packages/backend/src/server/api/EndpointsModule.ts4
-rw-r--r--packages/backend/src/server/api/SigninApiService.ts20
-rw-r--r--packages/backend/src/server/api/SignupApiService.ts32
-rw-r--r--packages/backend/src/server/api/endpoints.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/approve-user.ts61
-rw-r--r--packages/backend/src/server/api/endpoints/admin/meta.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/admin/show-user.ts2
-rw-r--r--packages/backend/src/server/api/endpoints/admin/update-meta.ts5
-rw-r--r--packages/backend/src/server/api/endpoints/meta.ts5
-rw-r--r--packages/backend/src/types.ts6
-rw-r--r--packages/frontend/src/components/MkSignupDialog.form.vue14
-rw-r--r--packages/frontend/src/components/MkSignupDialog.vue5
-rw-r--r--packages/frontend/src/components/MkVisitorDashboard.vue3
-rw-r--r--packages/frontend/src/pages/admin-user.vue121
-rw-r--r--packages/frontend/src/pages/admin/moderation.vue7
-rw-r--r--packages/frontend/src/pages/admin/modlog.ModLog.vue6
-rw-r--r--packages/misskey-js/src/consts.ts6
-rw-r--r--packages/misskey-js/src/entities.ts1
24 files changed, 330 insertions, 29 deletions
diff --git a/packages/backend/migration/1697580470000-approvalSignup.js b/packages/backend/migration/1697580470000-approvalSignup.js
new file mode 100644
index 0000000000..c5f8255d49
--- /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 359957cd52..32e3dee937 100644
--- a/packages/backend/src/core/SignupService.ts
+++ b/packages/backend/src/core/SignupService.ts
@@ -48,10 +48,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)) {
@@ -85,7 +87,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');
@@ -110,6 +111,9 @@ export class SignupService {
));
let account!: MiUser;
+ let defaultApproval = false;
+
+ if (!instance.approvalRequiredForSignup) defaultApproval = true;
// Start transaction
await this.db.transaction(async transactionalEntityManager => {
@@ -127,6 +131,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 3de95129fc..a24319c45a 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 e4abe42de5..e34c51a69b 100644
--- a/packages/backend/src/models/Meta.ts
+++ b/packages/backend/src/models/Meta.ts
@@ -177,6 +177,11 @@ export class MiMeta {
@Column('boolean', {
default: false,
})
+ public approvalRequiredForSignup: boolean;
+
+ @Column('boolean', {
+ default: false,
+ })
public enableHcaptcha: boolean;
@Column('varchar', {
diff --git a/packages/backend/src/models/User.ts b/packages/backend/src/models/User.ts
index 4704c607a8..81b46d5436 100644
--- a/packages/backend/src/models/User.ts
+++ b/packages/backend/src/models/User.ts
@@ -272,6 +272,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 8b1f8f617f..6b26bd228c 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 9aa75b0af8..079040b85f 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -62,6 +62,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';
@@ -415,6 +416,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 };
@@ -772,6 +774,7 @@ const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.de
$admin_showUser,
$admin_showUsers,
$admin_suspendUser,
+ $admin_approveUser,
$admin_unsuspendUser,
$admin_updateMeta,
$admin_deleteAccount,
@@ -1123,6 +1126,7 @@ const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.de
$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 2d4de605ea..fd247df22a 100644
--- a/packages/backend/src/server/api/SigninApiService.ts
+++ b/packages/backend/src/server/api/SigninApiService.ts
@@ -21,6 +21,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';
@@ -46,6 +47,7 @@ export class SigninApiService {
private signinService: SigninService,
private userAuthService: UserAuthService,
private webAuthnService: WebAuthnService,
+ private metaService: MetaService,
) {
}
@@ -64,6 +66,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'];
@@ -123,6 +127,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 argon2.verify(profile.password!, password) || bcrypt.compareSync(password, profile.password!);
@@ -147,6 +162,8 @@ export class SigninApiService {
password: newHash
});
}
+ if (!instance.approvalRequiredForSignup && !user.approved) this.usersRepository.update(user.id, { approved: true });
+
return this.signinService.signin(request, reply, user);
} else {
return await fail(403, {
@@ -176,6 +193,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) {
@@ -187,6 +206,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 02e5cd4fdf..53f770e172 100644
--- a/packages/backend/src/server/api/SignupApiService.ts
+++ b/packages/backend/src/server/api/SignupApiService.ts
@@ -22,6 +22,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 {
@@ -63,6 +64,7 @@ export class SignupApiService {
host?: string;
invitationCode?: string;
emailAddress?: string;
+ reason?: string;
'hcaptcha-response'?: string;
'g-recaptcha-response'?: string;
'turnstile-response'?: string;
@@ -100,6 +102,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) {
@@ -115,6 +118,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) {
@@ -170,6 +180,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}`;
@@ -187,6 +198,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 {
try {
const { account, secret } = await this.signupService.signup({
@@ -222,12 +246,15 @@ export class SignupApiService {
const code = body['code'];
+ const instance = await this.metaService.fetch(true);
+
try {
const pendingUser = await this.userPendingsRepository.findOneByOrFail({ code });
const { account, secret } = await this.signupService.signup({
username: pendingUser.username,
passwordHash: pendingUser.password,
+ reason: pendingUser.reason,
});
this.userPendingsRepository.delete({
@@ -250,6 +277,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 d7f5611f55..e978093364 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -62,6 +62,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';
@@ -413,6 +414,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 0000000000..0ea656ddaf
--- /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 88725ddbbf..763c4ea807 100644
--- a/packages/backend/src/server/api/endpoints/admin/meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/meta.ts
@@ -32,6 +32,10 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
+ approvalRequiredForSignup: {
+ type: 'boolean',
+ optional: false, nullable: false,
+ },
enableHcaptcha: {
type: 'boolean',
optional: false, nullable: false,
@@ -353,6 +357,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 f550c4fd28..5ad90f48b4 100644
--- a/packages/backend/src/server/api/endpoints/admin/show-user.ts
+++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts
@@ -73,6 +73,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 40cc50fe7c..be14037a76 100644
--- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts
+++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts
@@ -59,6 +59,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 },
@@ -249,6 +250,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 d37919b474..dbd72763bf 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,
@@ -308,6 +312,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 316073c992..dfc6bcba9c 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -30,6 +30,7 @@ export const ffVisibility = ['public', 'followers', 'private'] as const;
export const moderationLogTypes = [
'updateServerSettings',
'suspend',
+ 'approve',
'unsuspend',
'updateUserNote',
'addCustomEmoji',
@@ -72,6 +73,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 174b042649..8975842c63 100644
--- a/packages/frontend/src/components/MkSignupDialog.form.vue
+++ b/packages/frontend/src/components/MkSignupDialog.form.vue
@@ -61,6 +61,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-if="passwordRetypeState == 'not-match'" style="color: var(--error)"><i class="ph-warning ph-bold ph-lg 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 @@ let username: string = $ref('');
let password: string = $ref('');
let retypedPassword: string = $ref('');
let invitationCode: string = $ref('');
+let reason: string = $ref('');
let email = $ref('');
let usernameState: null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range' = $ref(null);
let emailState: null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error' = $ref(null);
@@ -249,6 +255,7 @@ async function onSubmit(): Promise<void> {
password,
emailAddress: email,
invitationCode,
+ reason,
'hcaptcha-response': hCaptchaResponse,
'g-recaptcha-response': reCaptchaResponse,
'turnstile-response': turnstileResponse,
@@ -260,6 +267,13 @@ async function onSubmit(): Promise<void> {
text: i18n.t('_signup.emailSent', { email }),
});
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,
diff --git a/packages/frontend/src/components/MkSignupDialog.vue b/packages/frontend/src/components/MkSignupDialog.vue
index d860ba5fe6..20b7cb6348 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>
@@ -64,6 +64,9 @@ function onSignup(res) {
function onSignupEmailPending() {
dialog.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 d507a3604a..412f58363c 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 f639054a66..dce74c0ea9 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>
@@ -176,6 +177,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>
@@ -222,8 +237,11 @@ let ips = $ref(null);
let ap = $ref(null);
let moderator = $ref(false);
let silenced = $ref(false);
+let approved = $ref(false);
let suspended = $ref(false);
let moderationNote = $ref('');
+let signupReason = $ref('');
+
const filesPagination = {
endpoint: 'admin/drive/files' as const,
limit: 10,
@@ -253,8 +271,10 @@ function createFetcher() {
ips = _ips;
moderator = info.isModerator;
silenced = info.isSilenced;
+ approved = info.approved;
suspended = info.isSuspended;
moderationNote = info.moderationNote;
+ signupReason = info.signupReason;
watch($$(moderationNote), async () => {
await os.api('admin/update-user-note', { userId: user.id, text: moderationNote });
@@ -346,6 +366,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');
@@ -432,31 +462,60 @@ watch($$(user), () => {
const headerActions = $computed(() => []);
-const headerTabs = $computed(() => [{
- key: 'overview',
- title: i18n.ts.overview,
- icon: 'ph-info ph-bold ph-lg',
-}, {
- key: 'roles',
- title: i18n.ts.roles,
- icon: 'ph-seal-check ph-bold pg-lg',
-}, {
- key: 'announcements',
- title: i18n.ts.announcements,
- icon: 'ph-megaphone ph-bold ph-lg',
-}, {
- key: 'drive',
- title: i18n.ts.drive,
- icon: 'ph-cloud ph-bold ph-lg',
-}, {
- key: 'chart',
- title: i18n.ts.charts,
- icon: 'ph-chart-line ph-bold pg-lg',
-}, {
- key: 'raw',
- title: 'Raw',
- icon: 'ph-code ph-bold pg-lg',
-}]);
+const headerTabs = $computed(() => iAmAdmin && !approved ?
+ [{
+ key: 'overview',
+ title: i18n.ts.overview,
+ icon: 'ph-info ph-bold ph-lg',
+ }, {
+ key: 'roles',
+ title: i18n.ts.roles,
+ icon: 'ph-seal-check ph-bold pg-lg',
+ }, {
+ key: 'announcements',
+ title: i18n.ts.announcements,
+ icon: 'ph-megaphone ph-bold ph-lg',
+ }, {
+ key: 'drive',
+ title: i18n.ts.drive,
+ icon: 'ph-cloud ph-bold ph-lg',
+ }, {
+ key: 'chart',
+ title: i18n.ts.charts,
+ icon: 'ph-chart-line ph-bold pg-lg',
+ }, {
+ key: 'raw',
+ title: 'Raw',
+ icon: 'ph-code ph-bold pg-lg',
+ }, {
+ key: 'approval',
+ title: 'Approval',
+ icon: 'ph-eye ph-bold pg-lg',
+ }] : [{
+ key: 'overview',
+ title: i18n.ts.overview,
+ icon: 'ph-info ph-bold ph-lg',
+ }, {
+ key: 'roles',
+ title: i18n.ts.roles,
+ icon: 'ph-seal-check ph-bold pg-lg',
+ }, {
+ key: 'announcements',
+ title: i18n.ts.announcements,
+ icon: 'ph-megaphone ph-bold ph-lg',
+ }, {
+ key: 'drive',
+ title: i18n.ts.drive,
+ icon: 'ph-cloud ph-bold ph-lg',
+ }, {
+ key: 'chart',
+ title: i18n.ts.charts,
+ icon: 'ph-chart-line ph-bold pg-lg',
+ }, {
+ key: 'raw',
+ title: 'Raw',
+ icon: 'ph-code ph-bold pg-lg',
+ }]);
definePageMetadata(computed(() => ({
title: user ? acct(user) : i18n.ts.userInfo,
@@ -547,6 +606,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 5e0d92376a..379a78580d 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">
@@ -71,6 +75,7 @@ import FormLink from '@/components/form/link.vue';
let enableRegistration: boolean = $ref(false);
let emailRequiredForSignup: boolean = $ref(false);
+let approvalRequiredForSignup: boolean = $ref(false);
let sensitiveWords: string = $ref('');
let preservedUsernames: string = $ref('');
let tosUrl: string | null = $ref(null);
@@ -80,6 +85,7 @@ async function init() {
const meta = await os.api('admin/meta');
enableRegistration = !meta.disableRegistration;
emailRequiredForSignup = meta.emailRequiredForSignup;
+ approvalRequiredForSignup = meta.approvalRequiredForSignup;
sensitiveWords = meta.sensitiveWords.join('\n');
preservedUsernames = meta.preservedUsernames.join('\n');
tosUrl = meta.tosUrl;
@@ -90,6 +96,7 @@ function save() {
os.apiWithDialog('admin/update-meta', {
disableRegistration: !enableRegistration,
emailRequiredForSignup,
+ approvalRequiredForSignup,
tosUrl,
privacyPolicyUrl,
sensitiveWords: sensitiveWords.split('\n'),
diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue
index c70bf784e5..ffb03cfa25 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'].includes(log.type),
[$style.logYellow]: ['markSensitiveDriveFile', 'resetPassword'].includes(log.type),
- [$style.logRed]: ['suspend', 'deleteRole', 'suspendRemoteInstance', 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'deleteCustomEmoji', 'deleteNote', 'deleteDriveFile', 'deleteAd'].includes(log.type)
+ [$style.logRed]: ['suspend', 'approve', 'deleteRole', 'suspendRemoteInstance', 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'deleteCustomEmoji', 'deleteNote', 'deleteDriveFile', 'deleteAd'].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>
@@ -65,6 +66,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 23659ed086..c01a878121 100644
--- a/packages/misskey-js/src/consts.ts
+++ b/packages/misskey-js/src/consts.ts
@@ -48,6 +48,7 @@ export const permissions = [
export const moderationLogTypes = [
'updateServerSettings',
'suspend',
+ 'approve',
'unsuspend',
'updateUserNote',
'addCustomEmoji',
@@ -87,6 +88,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/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts
index 5145b6a935..2011bec955 100644
--- a/packages/misskey-js/src/entities.ts
+++ b/packages/misskey-js/src/entities.ts
@@ -348,6 +348,7 @@ export type LiteInstanceMetadata = {
driveCapacityPerLocalUserMb: number;
driveCapacityPerRemoteUserMb: number;
emailRequiredForSignup: boolean;
+ approvalRequiredForSignup: boolean;
enableHcaptcha: boolean;
hcaptchaSiteKey: string | null;
enableRecaptcha: boolean;