diff options
| author | Mar0xy <marie@kaifa.ch> | 2023-10-18 02:41:36 +0200 |
|---|---|---|
| committer | Mar0xy <marie@kaifa.ch> | 2023-10-18 02:41:36 +0200 |
| commit | 2f2d88dcfc76bac6815d60fa9915b8e797853292 (patch) | |
| tree | fa3e903e9444ee4b92d91a6ff58264a4387d7523 | |
| parent | chore: change some misskey references to sharkey (diff) | |
| download | sharkey-2f2d88dcfc76bac6815d60fa9915b8e797853292.tar.gz sharkey-2f2d88dcfc76bac6815d60fa9915b8e797853292.tar.bz2 sharkey-2f2d88dcfc76bac6815d60fa9915b8e797853292.zip | |
add: Require Approval for Signup
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; |