add: Require Approval for Signup

This commit is contained in:
Mar0xy 2023-10-18 02:41:36 +02:00
parent 5c7f517895
commit 2f2d88dcfc
No known key found for this signature in database
GPG Key ID: 56569BBE47D2C828
24 changed files with 330 additions and 29 deletions

View File

@ -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"`);
}
}

View File

@ -48,10 +48,12 @@ export class SignupService {
password?: string | null; password?: string | null;
passwordHash?: MiUserProfile['password'] | null; passwordHash?: MiUserProfile['password'] | null;
host?: string | null; host?: string | null;
reason?: string | null;
ignorePreservedUsernames?: boolean; ignorePreservedUsernames?: boolean;
}) { }) {
const { username, password, passwordHash, host } = opts; const { username, password, passwordHash, host, reason } = opts;
let hash = passwordHash; let hash = passwordHash;
const instance = await this.metaService.fetch(true);
// Validate username // Validate username
if (!this.userEntityService.validateLocalUsername(username)) { if (!this.userEntityService.validateLocalUsername(username)) {
@ -85,7 +87,6 @@ export class SignupService {
const isTheFirstUser = (await this.usersRepository.countBy({ host: IsNull() })) === 0; const isTheFirstUser = (await this.usersRepository.countBy({ host: IsNull() })) === 0;
if (!opts.ignorePreservedUsernames && !isTheFirstUser) { if (!opts.ignorePreservedUsernames && !isTheFirstUser) {
const instance = await this.metaService.fetch(true);
const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase()); const isPreserved = instance.preservedUsernames.map(x => x.toLowerCase()).includes(username.toLowerCase());
if (isPreserved) { if (isPreserved) {
throw new Error('USED_USERNAME'); throw new Error('USED_USERNAME');
@ -110,6 +111,9 @@ export class SignupService {
)); ));
let account!: MiUser; let account!: MiUser;
let defaultApproval = false;
if (!instance.approvalRequiredForSignup) defaultApproval = true;
// Start transaction // Start transaction
await this.db.transaction(async transactionalEntityManager => { await this.db.transaction(async transactionalEntityManager => {
@ -127,6 +131,8 @@ export class SignupService {
host: this.utilityService.toPunyNullable(host), host: this.utilityService.toPunyNullable(host),
token: secret, token: secret,
isRoot: isTheFirstUser, isRoot: isTheFirstUser,
approved: defaultApproval,
signupReason: reason,
})); }));
await transactionalEntityManager.save(new MiUserKeypair({ await transactionalEntityManager.save(new MiUserKeypair({

View File

@ -489,6 +489,8 @@ export class UserEntityService implements OnModuleInit {
...(opts.includeSecrets ? { ...(opts.includeSecrets ? {
email: profile!.email, email: profile!.email,
emailVerified: profile!.emailVerified, emailVerified: profile!.emailVerified,
approved: user.approved,
signupReason: user.signupReason,
securityKeysList: profile!.twoFactorEnabled securityKeysList: profile!.twoFactorEnabled
? this.userSecurityKeysRepository.find({ ? this.userSecurityKeysRepository.find({
where: { where: {

View File

@ -174,6 +174,11 @@ export class MiMeta {
}) })
public emailRequiredForSignup: boolean; public emailRequiredForSignup: boolean;
@Column('boolean', {
default: false,
})
public approvalRequiredForSignup: boolean;
@Column('boolean', { @Column('boolean', {
default: false, default: false,
}) })

View File

@ -272,6 +272,16 @@ export class MiUser {
}) })
public token: string | null; 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>) { constructor(data: Partial<MiUser>) {
if (data == null) return; if (data == null) return;

View File

@ -31,4 +31,9 @@ export class MiUserPending {
length: 128, length: 128,
}) })
public password: string; public password: string;
@Column('varchar', {
length: 1000,
})
public reason: string;
} }

View File

@ -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_showUser from './endpoints/admin/show-user.js';
import * as ep___admin_showUsers from './endpoints/admin/show-users.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_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_unsuspendUser from './endpoints/admin/unsuspend-user.js';
import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js';
import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.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_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_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_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_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_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 }; 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_showUser,
$admin_showUsers, $admin_showUsers,
$admin_suspendUser, $admin_suspendUser,
$admin_approveUser,
$admin_unsuspendUser, $admin_unsuspendUser,
$admin_updateMeta, $admin_updateMeta,
$admin_deleteAccount, $admin_deleteAccount,
@ -1123,6 +1126,7 @@ const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.de
$admin_showUser, $admin_showUser,
$admin_showUsers, $admin_showUsers,
$admin_suspendUser, $admin_suspendUser,
$admin_approveUser,
$admin_unsuspendUser, $admin_unsuspendUser,
$admin_updateMeta, $admin_updateMeta,
$admin_deleteAccount, $admin_deleteAccount,

View File

@ -21,6 +21,7 @@ import { IdService } from '@/core/IdService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { WebAuthnService } from '@/core/WebAuthnService.js'; import { WebAuthnService } from '@/core/WebAuthnService.js';
import { UserAuthService } from '@/core/UserAuthService.js'; import { UserAuthService } from '@/core/UserAuthService.js';
import { MetaService } from '@/core/MetaService.js';
import { RateLimiterService } from './RateLimiterService.js'; import { RateLimiterService } from './RateLimiterService.js';
import { SigninService } from './SigninService.js'; import { SigninService } from './SigninService.js';
import type { AuthenticationResponseJSON } from '@simplewebauthn/typescript-types'; import type { AuthenticationResponseJSON } from '@simplewebauthn/typescript-types';
@ -46,6 +47,7 @@ export class SigninApiService {
private signinService: SigninService, private signinService: SigninService,
private userAuthService: UserAuthService, private userAuthService: UserAuthService,
private webAuthnService: WebAuthnService, 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-Origin', this.config.url);
reply.header('Access-Control-Allow-Credentials', 'true'); reply.header('Access-Control-Allow-Credentials', 'true');
const instance = await this.metaService.fetch(true);
const body = request.body; const body = request.body;
const username = body['username']; const username = body['username'];
const password = body['password']; const password = body['password'];
@ -123,6 +127,17 @@ export class SigninApiService {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id }); 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 // Compare password
const same = await argon2.verify(profile.password!, password) || bcrypt.compareSync(password, profile.password!); const same = await argon2.verify(profile.password!, password) || bcrypt.compareSync(password, profile.password!);
@ -147,6 +162,8 @@ export class SigninApiService {
password: newHash password: newHash
}); });
} }
if (!instance.approvalRequiredForSignup && !user.approved) this.usersRepository.update(user.id, { approved: true });
return this.signinService.signin(request, reply, user); return this.signinService.signin(request, reply, user);
} else { } else {
return await fail(403, { 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); return this.signinService.signin(request, reply, user);
} else if (body.credential) { } else if (body.credential) {
if (!same && !profile.usePasswordLessLogin) { if (!same && !profile.usePasswordLessLogin) {
@ -187,6 +206,7 @@ export class SigninApiService {
const authorized = await this.webAuthnService.verifyAuthentication(user.id, body.credential); const authorized = await this.webAuthnService.verifyAuthentication(user.id, body.credential);
if (authorized) { if (authorized) {
if (!instance.approvalRequiredForSignup && !user.approved) this.usersRepository.update(user.id, { approved: true });
return this.signinService.signin(request, reply, user); return this.signinService.signin(request, reply, user);
} else { } else {
return await fail(403, { return await fail(403, {

View File

@ -22,6 +22,7 @@ import { bindThis } from '@/decorators.js';
import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js'; import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js';
import { SigninService } from './SigninService.js'; import { SigninService } from './SigninService.js';
import type { FastifyRequest, FastifyReply } from 'fastify'; import type { FastifyRequest, FastifyReply } from 'fastify';
import instance from './endpoints/charts/instance.js';
@Injectable() @Injectable()
export class SignupApiService { export class SignupApiService {
@ -63,6 +64,7 @@ export class SignupApiService {
host?: string; host?: string;
invitationCode?: string; invitationCode?: string;
emailAddress?: string; emailAddress?: string;
reason?: string;
'hcaptcha-response'?: string; 'hcaptcha-response'?: string;
'g-recaptcha-response'?: string; 'g-recaptcha-response'?: string;
'turnstile-response'?: string; 'turnstile-response'?: string;
@ -100,6 +102,7 @@ export class SignupApiService {
const password = body['password']; const password = body['password'];
const host: string | null = process.env.NODE_ENV === 'test' ? (body['host'] ?? null) : null; const host: string | null = process.env.NODE_ENV === 'test' ? (body['host'] ?? null) : null;
const invitationCode = body['invitationCode']; const invitationCode = body['invitationCode'];
const reason = body['reason'];
const emailAddress = body['emailAddress']; const emailAddress = body['emailAddress'];
if (instance.emailRequiredForSignup) { 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; let ticket: MiRegistrationTicket | null = null;
if (instance.disableRegistration) { if (instance.disableRegistration) {
@ -170,6 +180,7 @@ export class SignupApiService {
email: emailAddress!, email: emailAddress!,
username: username, username: username,
password: hash, password: hash,
reason: reason,
}).then(x => this.userPendingsRepository.findOneByOrFail(x.identifiers[0])); }).then(x => this.userPendingsRepository.findOneByOrFail(x.identifiers[0]));
const link = `${this.config.url}/signup-complete/${code}`; const link = `${this.config.url}/signup-complete/${code}`;
@ -185,6 +196,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); reply.code(204);
return; return;
} else { } else {
@ -222,12 +246,15 @@ export class SignupApiService {
const code = body['code']; const code = body['code'];
const instance = await this.metaService.fetch(true);
try { try {
const pendingUser = await this.userPendingsRepository.findOneByOrFail({ code }); const pendingUser = await this.userPendingsRepository.findOneByOrFail({ code });
const { account, secret } = await this.signupService.signup({ const { account, secret } = await this.signupService.signup({
username: pendingUser.username, username: pendingUser.username,
passwordHash: pendingUser.password, passwordHash: pendingUser.password,
reason: pendingUser.reason,
}); });
this.userPendingsRepository.delete({ this.userPendingsRepository.delete({
@ -251,6 +278,11 @@ export class SignupApiService {
}); });
} }
if (instance.approvalRequiredForSignup) {
reply.code(204);
return;
}
return this.signinService.signin(request, reply, account as MiLocalUser); return this.signinService.signin(request, reply, account as MiLocalUser);
} catch (err) { } catch (err) {
throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString()); throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString());

View File

@ -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_showUser from './endpoints/admin/show-user.js';
import * as ep___admin_showUsers from './endpoints/admin/show-users.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_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_unsuspendUser from './endpoints/admin/unsuspend-user.js';
import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js';
import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.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-user', ep___admin_showUser],
['admin/show-users', ep___admin_showUsers], ['admin/show-users', ep___admin_showUsers],
['admin/suspend-user', ep___admin_suspendUser], ['admin/suspend-user', ep___admin_suspendUser],
['admin/approve-user', ep___admin_approveUser],
['admin/unsuspend-user', ep___admin_unsuspendUser], ['admin/unsuspend-user', ep___admin_unsuspendUser],
['admin/update-meta', ep___admin_updateMeta], ['admin/update-meta', ep___admin_updateMeta],
['admin/delete-account', ep___admin_deleteAccount], ['admin/delete-account', ep___admin_deleteAccount],

View File

@ -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,
});
});
}
}

View File

@ -32,6 +32,10 @@ export const meta = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
approvalRequiredForSignup: {
type: 'boolean',
optional: false, nullable: false,
},
enableHcaptcha: { enableHcaptcha: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
@ -353,6 +357,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
privacyPolicyUrl: instance.privacyPolicyUrl, privacyPolicyUrl: instance.privacyPolicyUrl,
disableRegistration: instance.disableRegistration, disableRegistration: instance.disableRegistration,
emailRequiredForSignup: instance.emailRequiredForSignup, emailRequiredForSignup: instance.emailRequiredForSignup,
approvalRequiredForSignup: instance.approvalRequiredForSignup,
enableHcaptcha: instance.enableHcaptcha, enableHcaptcha: instance.enableHcaptcha,
hcaptchaSiteKey: instance.hcaptchaSiteKey, hcaptchaSiteKey: instance.hcaptchaSiteKey,
enableRecaptcha: instance.enableRecaptcha, enableRecaptcha: instance.enableRecaptcha,

View File

@ -73,6 +73,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return { return {
email: profile.email, email: profile.email,
emailVerified: profile.emailVerified, emailVerified: profile.emailVerified,
approved: user.approved,
signupReason: user.signupReason,
autoAcceptFollowed: profile.autoAcceptFollowed, autoAcceptFollowed: profile.autoAcceptFollowed,
noCrawle: profile.noCrawle, noCrawle: profile.noCrawle,
preventAiLearning: profile.preventAiLearning, preventAiLearning: profile.preventAiLearning,

View File

@ -59,6 +59,7 @@ export const paramDef = {
cacheRemoteFiles: { type: 'boolean' }, cacheRemoteFiles: { type: 'boolean' },
cacheRemoteSensitiveFiles: { type: 'boolean' }, cacheRemoteSensitiveFiles: { type: 'boolean' },
emailRequiredForSignup: { type: 'boolean' }, emailRequiredForSignup: { type: 'boolean' },
approvalRequiredForSignup: { type: 'boolean' },
enableHcaptcha: { type: 'boolean' }, enableHcaptcha: { type: 'boolean' },
hcaptchaSiteKey: { type: 'string', nullable: true }, hcaptchaSiteKey: { type: 'string', nullable: true },
hcaptchaSecretKey: { 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; set.emailRequiredForSignup = ps.emailRequiredForSignup;
} }
if (ps.approvalRequiredForSignup !== undefined) {
set.approvalRequiredForSignup = ps.approvalRequiredForSignup;
}
if (ps.enableHcaptcha !== undefined) { if (ps.enableHcaptcha !== undefined) {
set.enableHcaptcha = ps.enableHcaptcha; set.enableHcaptcha = ps.enableHcaptcha;
} }

View File

@ -100,6 +100,10 @@ export const meta = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
approvalRequiredForSignup: {
type: 'boolean',
optional: false, nullable: false,
},
enableHcaptcha: { enableHcaptcha: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
@ -308,6 +312,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
privacyPolicyUrl: instance.privacyPolicyUrl, privacyPolicyUrl: instance.privacyPolicyUrl,
disableRegistration: instance.disableRegistration, disableRegistration: instance.disableRegistration,
emailRequiredForSignup: instance.emailRequiredForSignup, emailRequiredForSignup: instance.emailRequiredForSignup,
approvalRequiredForSignup: instance.approvalRequiredForSignup,
enableHcaptcha: instance.enableHcaptcha, enableHcaptcha: instance.enableHcaptcha,
hcaptchaSiteKey: instance.hcaptchaSiteKey, hcaptchaSiteKey: instance.hcaptchaSiteKey,
enableRecaptcha: instance.enableRecaptcha, enableRecaptcha: instance.enableRecaptcha,

View File

@ -30,6 +30,7 @@ export const ffVisibility = ['public', 'followers', 'private'] as const;
export const moderationLogTypes = [ export const moderationLogTypes = [
'updateServerSettings', 'updateServerSettings',
'suspend', 'suspend',
'approve',
'unsuspend', 'unsuspend',
'updateUserNote', 'updateUserNote',
'addCustomEmoji', 'addCustomEmoji',
@ -72,6 +73,11 @@ export type ModerationLogPayloads = {
userUsername: string; userUsername: string;
userHost: string | null; userHost: string | null;
}; };
approve: {
userId: string;
userUsername: string;
userHost: string | null;
};
unsuspend: { unsuspend: {
userId: string; userId: string;
userUsername: string; userUsername: string;

View File

@ -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> <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> </template>
</MkInput> </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.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.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"/> <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<{ const emit = defineEmits<{
(ev: 'signup', user: Record<string, any>): void; (ev: 'signup', user: Record<string, any>): void;
(ev: 'signupEmailPending'): void; (ev: 'signupEmailPending'): void;
(ev: 'approvalPending'): void;
}>(); }>();
const host = toUnicode(config.host); const host = toUnicode(config.host);
@ -109,6 +114,7 @@ let username: string = $ref('');
let password: string = $ref(''); let password: string = $ref('');
let retypedPassword: string = $ref(''); let retypedPassword: string = $ref('');
let invitationCode: string = $ref(''); let invitationCode: string = $ref('');
let reason: string = $ref('');
let email = $ref(''); let email = $ref('');
let usernameState: null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range' = $ref(null); 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); 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, password,
emailAddress: email, emailAddress: email,
invitationCode, invitationCode,
reason,
'hcaptcha-response': hCaptchaResponse, 'hcaptcha-response': hCaptchaResponse,
'g-recaptcha-response': reCaptchaResponse, 'g-recaptcha-response': reCaptchaResponse,
'turnstile-response': turnstileResponse, 'turnstile-response': turnstileResponse,
@ -260,6 +267,13 @@ async function onSubmit(): Promise<void> {
text: i18n.t('_signup.emailSent', { email }), text: i18n.t('_signup.emailSent', { email }),
}); });
emit('signupEmailPending'); emit('signupEmailPending');
} else if (instance.approvalRequiredForSignup) {
os.alert({
type: 'success',
title: i18n.ts._signup.almostThere,
text: i18n.t('_signup.emailSent', { email }),
});
emit('approvalPending');
} else { } else {
const res = await os.api('signin', { const res = await os.api('signin', {
username, username,

View File

@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<XServerRules @done="isAcceptedServerRule = true" @cancel="dialog.close()"/> <XServerRules @done="isAcceptedServerRule = true" @cancel="dialog.close()"/>
</template> </template>
<template v-else> <template v-else>
<XSignup :autoSet="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending"/> <XSignup :autoSet="autoSet" @signup="onSignup" @signupEmailPending="onSignupEmailPending" @approvalPending="onApprovalPending"/>
</template> </template>
</Transition> </Transition>
</div> </div>
@ -64,6 +64,9 @@ function onSignup(res) {
function onSignupEmailPending() { function onSignupEmailPending() {
dialog.close(); dialog.close();
} }
function onApprovalPending() {
dialog.close();
}
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View File

@ -21,6 +21,9 @@ SPDX-License-Identifier: AGPL-3.0-only
<div v-if="instance.disableRegistration" :class="$style.mainWarn"> <div v-if="instance.disableRegistration" :class="$style.mainWarn">
<MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo> <MkInfo warn>{{ i18n.ts.invitationRequiredToRegister }}</MkInfo>
</div> </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"> <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 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> <MkButton :class="$style.mainAction" full rounded @click="exploreOtherServers()">{{ i18n.ts.exploreOtherServers }}</MkButton>

View File

@ -15,6 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span class="name"><MkUserName class="name" :user="user"/></span> <span class="name"><MkUserName class="name" :user="user"/></span>
<span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span> <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span>
<span class="state"> <span class="state">
<span v-if="!approved" class="silenced">Not Approved</span>
<span v-if="suspended" class="suspended">Suspended</span> <span v-if="suspended" class="suspended">Suspended</span>
<span v-if="silenced" class="silenced">Silenced</span> <span v-if="silenced" class="silenced">Silenced</span>
<span v-if="moderator" class="moderator">Moderator</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 tall :value="user">
</MkObjectView> </MkObjectView>
</div> </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> </FormSuspense>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
@ -222,8 +237,11 @@ let ips = $ref(null);
let ap = $ref(null); let ap = $ref(null);
let moderator = $ref(false); let moderator = $ref(false);
let silenced = $ref(false); let silenced = $ref(false);
let approved = $ref(false);
let suspended = $ref(false); let suspended = $ref(false);
let moderationNote = $ref(''); let moderationNote = $ref('');
let signupReason = $ref('');
const filesPagination = { const filesPagination = {
endpoint: 'admin/drive/files' as const, endpoint: 'admin/drive/files' as const,
limit: 10, limit: 10,
@ -253,8 +271,10 @@ function createFetcher() {
ips = _ips; ips = _ips;
moderator = info.isModerator; moderator = info.isModerator;
silenced = info.isSilenced; silenced = info.isSilenced;
approved = info.approved;
suspended = info.isSuspended; suspended = info.isSuspended;
moderationNote = info.moderationNote; moderationNote = info.moderationNote;
signupReason = info.signupReason;
watch($$(moderationNote), async () => { watch($$(moderationNote), async () => {
await os.api('admin/update-user-note', { userId: user.id, text: moderationNote }); 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() { async function assignRole() {
const roles = await os.api('admin/roles/list'); const roles = await os.api('admin/roles/list');
@ -432,31 +462,60 @@ watch($$(user), () => {
const headerActions = $computed(() => []); const headerActions = $computed(() => []);
const headerTabs = $computed(() => [{ const headerTabs = $computed(() => iAmAdmin && !approved ?
key: 'overview', [{
title: i18n.ts.overview, key: 'overview',
icon: 'ph-info ph-bold ph-lg', title: i18n.ts.overview,
}, { icon: 'ph-info ph-bold ph-lg',
key: 'roles', }, {
title: i18n.ts.roles, key: 'roles',
icon: 'ph-seal-check ph-bold pg-lg', title: i18n.ts.roles,
}, { icon: 'ph-seal-check ph-bold pg-lg',
key: 'announcements', }, {
title: i18n.ts.announcements, key: 'announcements',
icon: 'ph-megaphone ph-bold ph-lg', title: i18n.ts.announcements,
}, { icon: 'ph-megaphone ph-bold ph-lg',
key: 'drive', }, {
title: i18n.ts.drive, key: 'drive',
icon: 'ph-cloud ph-bold ph-lg', title: i18n.ts.drive,
}, { icon: 'ph-cloud ph-bold ph-lg',
key: 'chart', }, {
title: i18n.ts.charts, key: 'chart',
icon: 'ph-chart-line ph-bold pg-lg', title: i18n.ts.charts,
}, { icon: 'ph-chart-line ph-bold pg-lg',
key: 'raw', }, {
title: 'Raw', key: 'raw',
icon: 'ph-code ph-bold pg-lg', 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(() => ({ definePageMetadata(computed(() => ({
title: user ? acct(user) : i18n.ts.userInfo, 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>
<style lang="scss" module> <style lang="scss" module>

View File

@ -18,6 +18,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.emailRequiredForSignup }}</template> <template #label>{{ i18n.ts.emailRequiredForSignup }}</template>
</MkSwitch> </MkSwitch>
<MkSwitch v-model="approvalRequiredForSignup">
<template #label>Require approval for new sign-ups</template>
</MkSwitch>
<FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink> <FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink>
<MkInput v-model="tosUrl"> <MkInput v-model="tosUrl">
@ -71,6 +75,7 @@ import FormLink from '@/components/form/link.vue';
let enableRegistration: boolean = $ref(false); let enableRegistration: boolean = $ref(false);
let emailRequiredForSignup: boolean = $ref(false); let emailRequiredForSignup: boolean = $ref(false);
let approvalRequiredForSignup: boolean = $ref(false);
let sensitiveWords: string = $ref(''); let sensitiveWords: string = $ref('');
let preservedUsernames: string = $ref(''); let preservedUsernames: string = $ref('');
let tosUrl: string | null = $ref(null); let tosUrl: string | null = $ref(null);
@ -80,6 +85,7 @@ async function init() {
const meta = await os.api('admin/meta'); const meta = await os.api('admin/meta');
enableRegistration = !meta.disableRegistration; enableRegistration = !meta.disableRegistration;
emailRequiredForSignup = meta.emailRequiredForSignup; emailRequiredForSignup = meta.emailRequiredForSignup;
approvalRequiredForSignup = meta.approvalRequiredForSignup;
sensitiveWords = meta.sensitiveWords.join('\n'); sensitiveWords = meta.sensitiveWords.join('\n');
preservedUsernames = meta.preservedUsernames.join('\n'); preservedUsernames = meta.preservedUsernames.join('\n');
tosUrl = meta.tosUrl; tosUrl = meta.tosUrl;
@ -90,6 +96,7 @@ function save() {
os.apiWithDialog('admin/update-meta', { os.apiWithDialog('admin/update-meta', {
disableRegistration: !enableRegistration, disableRegistration: !enableRegistration,
emailRequiredForSignup, emailRequiredForSignup,
approvalRequiredForSignup,
tosUrl, tosUrl,
privacyPolicyUrl, privacyPolicyUrl,
sensitiveWords: sensitiveWords.split('\n'), sensitiveWords: sensitiveWords.split('\n'),

View File

@ -10,11 +10,12 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="{ :class="{
[$style.logGreen]: ['createRole', 'addCustomEmoji', 'createGlobalAnnouncement', 'createUserAnnouncement', 'createAd', 'createInvitation'].includes(log.type), [$style.logGreen]: ['createRole', 'addCustomEmoji', 'createGlobalAnnouncement', 'createUserAnnouncement', 'createAd', 'createInvitation'].includes(log.type),
[$style.logYellow]: ['markSensitiveDriveFile', 'resetPassword'].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> >{{ i18n.ts._moderationLogTypes[log.type] }}</b>
<span v-if="log.type === 'updateUserNote'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span> <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 === '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 === '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 === '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> <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'"> <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> <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>
<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'"> <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> <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>

View File

@ -48,6 +48,7 @@ export const permissions = [
export const moderationLogTypes = [ export const moderationLogTypes = [
'updateServerSettings', 'updateServerSettings',
'suspend', 'suspend',
'approve',
'unsuspend', 'unsuspend',
'updateUserNote', 'updateUserNote',
'addCustomEmoji', 'addCustomEmoji',
@ -87,6 +88,11 @@ export type ModerationLogPayloads = {
userUsername: string; userUsername: string;
userHost: string | null; userHost: string | null;
}; };
approve: {
userId: string;
userUsername: string;
userHost: string | null;
};
unsuspend: { unsuspend: {
userId: string; userId: string;
userUsername: string; userUsername: string;

View File

@ -348,6 +348,7 @@ export type LiteInstanceMetadata = {
driveCapacityPerLocalUserMb: number; driveCapacityPerLocalUserMb: number;
driveCapacityPerRemoteUserMb: number; driveCapacityPerRemoteUserMb: number;
emailRequiredForSignup: boolean; emailRequiredForSignup: boolean;
approvalRequiredForSignup: boolean;
enableHcaptcha: boolean; enableHcaptcha: boolean;
hcaptchaSiteKey: string | null; hcaptchaSiteKey: string | null;
enableRecaptcha: boolean; enableRecaptcha: boolean;