merge: approve signup system (#93)

This commit is contained in:
Marie 2023-10-18 21:07:53 +02:00 committed by GitHub
commit 887f422511
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 378 additions and 31 deletions

View File

@ -145,6 +145,7 @@ unblockConfirm: "Are you sure that you want to unblock this account?"
nsfwConfirm: "Are you sure that you want to mark all media from this account as NSFW?"
unNsfwConfirm: "Are you sure that you want to unmark all media from this account as NSFW?"
suspendConfirm: "Are you sure that you want to suspend this account?"
approveConfirm: "Are you sure that you want to approve this account?"
unsuspendConfirm: "Are you sure that you want to unsuspend this account?"
selectList: "Select a list"
editList: "Edit list"
@ -870,6 +871,7 @@ itsOff: "Disabled"
on: "On"
off: "Off"
emailRequiredForSignup: "Require email address for sign-up"
approvalRequiredForSignup: "Require approval for new users"
unread: "Unread"
filter: "Filter"
controlPanel: "Control Panel"
@ -928,6 +930,11 @@ requireAdminForView: "You must log in with an administrator account to view this
isSystemAccount: "An account created and automatically operated by the system."
typeToConfirm: "Please enter {x} to confirm"
deleteAccount: "Delete account"
approveAccount: "Approve"
denyAccount: "Deny & Delete"
approved: "Approved"
notApproved: "Not Approved"
approvalStatus: "Approval Status"
document: "Documentation"
numberOfPageCache: "Number of cached pages"
numberOfPageCacheDescription: "Increasing this number will improve convenience for but cause more load as more memory usage on the user's device."
@ -1019,6 +1026,7 @@ disableFederationConfirm: "Really disable federation?"
disableFederationConfirmWarn: "Even if defederated, posts will continue to be public unless set otherwise. You usually do not need to do this."
disableFederationOk: "Disable"
invitationRequiredToRegister: "This instance is invite-only. You must enter a valid invite code sign up."
approvalRequiredToRegister: "This instance is only accepting users who specify a reason for registration."
emailNotSupported: "This instance does not support sending emails"
postToTheChannel: "Post to channel"
cannotBeChangedLater: "This cannot be changed later."
@ -1544,6 +1552,8 @@ _signup:
almostThere: "Almost there"
emailAddressInfo: "Please enter your email address. It will not be made public."
emailSent: "A confirmation email has been sent to your email address ({email}). Please click the included link to complete account creation."
approvalPending: "Your account has been created and is awaiting approval."
reasonInfo: "Please enter a reason as to why you want to join the instance."
_accountDelete:
accountDelete: "Delete account"
mayTakeTime: "As account deletion is a resource-heavy process, it may take some time to complete depending on how much content you have created and how many files you have uploaded."

10
locales/index.d.ts vendored
View File

@ -148,6 +148,7 @@ export interface Locale {
"nsfwConfirm": string;
"unNsfwConfirm": string;
"suspendConfirm": string;
"approveConfirm": string;
"unsuspendConfirm": string;
"selectList": string;
"editList": string;
@ -873,6 +874,7 @@ export interface Locale {
"on": string;
"off": string;
"emailRequiredForSignup": string;
"approvalRequiredForSignup": string;
"unread": string;
"filter": string;
"controlPanel": string;
@ -931,6 +933,11 @@ export interface Locale {
"isSystemAccount": string;
"typeToConfirm": string;
"deleteAccount": string;
"approveAccount": string;
"denyAccount": string;
"approved": string;
"notApproved": string;
"approvalStatus": string;
"document": string;
"numberOfPageCache": string;
"numberOfPageCacheDescription": string;
@ -1022,6 +1029,7 @@ export interface Locale {
"disableFederationConfirmWarn": string;
"disableFederationOk": string;
"invitationRequiredToRegister": string;
"approvalRequiredToRegister": string;
"emailNotSupported": string;
"postToTheChannel": string;
"cannotBeChangedLater": string;
@ -1636,6 +1644,8 @@ export interface Locale {
"almostThere": string;
"emailAddressInfo": string;
"emailSent": string;
"approvalPending": string;
"reasonInfo": string;
};
"_accountDelete": {
"accountDelete": string;

View File

@ -145,6 +145,7 @@ unblockConfirm: "ブロック解除しますか?"
nsfwConfirm: "このアカウントからのすべてのメディアをNSFWとしてマークしてもよろしいですか"
unNsfwConfirm: "このアカウントのすべてのメディアをNSFWとしてマーク解除してもよろしいですか"
suspendConfirm: "凍結しますか?"
approveConfirm: "このアカウントを承認してもよろしいですか?"
unsuspendConfirm: "解凍しますか?"
selectList: "リストを選択"
editList: "リストを編集"
@ -870,6 +871,7 @@ itsOff: "オフになっています"
on: "オン"
off: "オフ"
emailRequiredForSignup: "アカウント登録にメールアドレスを必須にする"
approvalRequiredForSignup: "新規ユーザーの承認が必要"
unread: "未読"
filter: "フィルタ"
controlPanel: "コントロールパネル"
@ -928,6 +930,11 @@ requireAdminForView: "閲覧するには管理者アカウントでログイン
isSystemAccount: "システムにより自動で作成・管理されているアカウントです。"
typeToConfirm: "この操作を行うには {x} と入力してください"
deleteAccount: "アカウント削除"
approveAccount: "承認する"
denyAccount: "拒否と削除"
approved: "承認済み"
notApproved: "承認されていない"
approvalStatus: "承認状況"
document: "ドキュメント"
numberOfPageCache: "ページキャッシュ数"
numberOfPageCacheDescription: "多くすると利便性が向上しますが、負荷とメモリ使用量が増えます。"
@ -1019,6 +1026,7 @@ disableFederationConfirm: "連合なしにしますか?"
disableFederationConfirmWarn: "連合なしにしても投稿は非公開になりません。ほとんどの場合、連合なしにする必要はありません。"
disableFederationOk: "連合なしにする"
invitationRequiredToRegister: "現在このサーバーは招待制です。招待コードをお持ちの方のみ登録できます。"
approvalRequiredToRegister: "このインスタンスは、登録理由を指定したユーザーのみを受け入れています。"
emailNotSupported: "このサーバーではメール配信はサポートされていません"
postToTheChannel: "チャンネルに投稿"
cannotBeChangedLater: "後から変更できません。"
@ -1555,6 +1563,8 @@ _signup:
almostThere: "ほとんど完了です"
emailAddressInfo: "あなたが使っているメールアドレスを入力してください。メールアドレスが公開されることはありません。"
emailSent: "入力されたメールアドレス({email})宛に確認のメールが送信されました。メールに記載されたリンクにアクセスすると、アカウントの作成が完了します。"
approvalPending: "アカウントが作成され、承認待ちの状態です。"
reasonInfo: "インスタンスに参加したい理由を入力してください。"
_accountDelete:
accountDelete: "アカウントの削除"

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;
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({

View File

@ -316,6 +316,7 @@ export class ApPersonService implements OnModuleInit {
alsoKnownAs: person.alsoKnownAs,
isExplorable: person.discoverable,
username: person.preferredUsername,
approved: true,
usernameLower: person.preferredUsername?.toLowerCase(),
host,
inbox: person.inbox,

View File

@ -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: {

View File

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

View File

@ -278,6 +278,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;

View File

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

View File

@ -66,6 +66,7 @@ import * as ep___admin_unnsfwUser from './endpoints/admin/unnsfw-user.js';
import * as ep___admin_silenceUser from './endpoints/admin/silence-user.js';
import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-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_updateMeta from './endpoints/admin/update-meta.js';
import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js';
@ -423,6 +424,7 @@ const $admin_unnsfwUser: Provider = { provide: 'ep:admin/unnsfw-user', useClass:
const $admin_silenceUser: Provider = { provide: 'ep:admin/silence-user', useClass: ep___admin_silenceUser.default };
const $admin_unsilenceUser: Provider = { provide: 'ep:admin/unsilence-user', useClass: ep___admin_unsilenceUser.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 };
@ -784,6 +786,7 @@ const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.de
$admin_silenceUser,
$admin_unsilenceUser,
$admin_suspendUser,
$admin_approveUser,
$admin_unsuspendUser,
$admin_updateMeta,
$admin_deleteAccount,
@ -1139,6 +1142,7 @@ const $sponsors: Provider = { provide: 'ep:sponsors', useClass: ep___sponsors.de
$admin_silenceUser,
$admin_unsilenceUser,
$admin_suspendUser,
$admin_approveUser,
$admin_unsuspendUser,
$admin_updateMeta,
$admin_deleteAccount,

View File

@ -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, {

View File

@ -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}`;
@ -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);
return;
} else {
@ -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({
@ -251,6 +278,15 @@ export class SignupApiService {
});
}
if (instance.approvalRequiredForSignup) {
if (pendingUser.email) {
this.emailService.sendEmail(pendingUser.email, '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.');
}
return { pendingApproval: true };
}
return this.signinService.signin(request, reply, account as MiLocalUser);
} catch (err) {
throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString());

View File

@ -66,6 +66,7 @@ import * as ep___admin_unnsfwUser from './endpoints/admin/unnsfw-user.js';
import * as ep___admin_silenceUser from './endpoints/admin/silence-user.js';
import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-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_updateMeta from './endpoints/admin/update-meta.js';
import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js';
@ -421,6 +422,7 @@ const eps = [
['admin/silence-user', ep___admin_silenceUser],
['admin/unsilence-user', ep___admin_unsilenceUser],
['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],

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',
optional: false, nullable: false,
},
approvalRequiredForSignup: {
type: 'boolean',
optional: false, nullable: false,
},
enableHcaptcha: {
type: 'boolean',
optional: false, nullable: false,
@ -357,6 +361,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,

View File

@ -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,

View File

@ -34,7 +34,7 @@ export const paramDef = {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
offset: { type: 'integer', default: 0 },
sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt', '+lastActiveDate', '-lastActiveDate'] },
state: { type: 'string', enum: ['all', 'alive', 'available', 'admin', 'moderator', 'adminOrModerator', 'suspended'], default: 'all' },
state: { type: 'string', enum: ['all', 'alive', 'available', 'admin', 'moderator', 'adminOrModerator', 'suspended', 'approved'], default: 'all' },
origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' },
username: { type: 'string', nullable: true, default: null },
hostname: {
@ -63,6 +63,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
case 'available': query.where('user.isSuspended = FALSE'); break;
case 'alive': query.where('user.updatedAt > :date', { date: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5) }); break;
case 'suspended': query.where('user.isSuspended = TRUE'); break;
case 'approved': query.where('user.approved = FALSE'); break;
case 'admin': {
const adminIds = await this.roleService.getAdministratorIds();
if (adminIds.length === 0) return [];

View File

@ -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 },
@ -250,6 +251,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;
}

View File

@ -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,

View File

@ -28,7 +28,7 @@ export async function getInstance(
thumbnail: meta.backgroundImageUrl || '/static-assets/transparent.png',
languages: meta.langs,
registrations: !meta.disableRegistration || response.registrations,
approval_required: !response.registrations,
approval_required: meta.approvalRequiredForSignup,
invites_enabled: response.registrations,
configuration: {
accounts: {

View File

@ -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;

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>
</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.reasonInfo" class="_button _help"><i class="ph-question ph-bold ph-lg"></i></div></template>
<template #prefix><i class="ph-chalkboard-teacher 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.ts._signup.approvalPending,
});
emit('approvalPending');
} else {
const res = await os.api('signin', {
username,

View File

@ -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>

View File

@ -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>{{ i18n.ts.approvalRequiredToRegister }}</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>

View File

@ -15,6 +15,8 @@ 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">{{ i18n.ts.notApproved }}</span>
<span v-if="approved" class="moderator">{{ i18n.ts.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>
@ -178,6 +180,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>{{ i18n.ts.approvalStatus }}</template>
<template #value><span class="_monospace">{{ approved ? i18n.ts.approved : i18n.ts.notApproved }}</span></template>
</MkKeyValue>
<MkTextarea v-model="signupReason" readonly>
<template #label>Reason</template>
</MkTextarea>
<MkButton v-if="$i.isAdmin" inline success @click="approveAccount">{{ i18n.ts.approveAccount }}</MkButton>
<MkButton v-if="$i.isAdmin" inline danger @click="deleteAccount">{{ i18n.ts.denyAccount }}</MkButton>
</div>
</FormSuspense>
</MkSpacer>
</MkStickyContainer>
@ -224,9 +240,12 @@ 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 markedAsNSFW = $ref(false);
let moderationNote = $ref('');
let signupReason = $ref('');
const filesPagination = {
endpoint: 'admin/drive/files' as const,
limit: 10,
@ -256,8 +275,10 @@ function createFetcher() {
ips = _ips;
moderator = info.isModerator;
silenced = info.isSilenced;
approved = info.approved;
suspended = info.isSuspended;
moderationNote = info.moderationNote;
signupReason = info.signupReason;
markedAsNSFW = info.alwaysMarkNsfw;
watch($$(moderationNote), async () => {
@ -376,6 +397,16 @@ async function deleteAccount() {
}
}
async function approveAccount() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.approveConfirm,
});
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');
@ -462,31 +493,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,
@ -577,6 +637,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>

View File

@ -18,6 +18,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.emailRequiredForSignup }}</template>
</MkSwitch>
<MkSwitch v-model="approvalRequiredForSignup">
<template #label>{{ i18n.ts.approvalRequiredForSignup }}</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'),

View File

@ -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>

View File

@ -21,6 +21,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.state }}</template>
<option value="all">{{ i18n.ts.all }}</option>
<option value="available">{{ i18n.ts.normal }}</option>
<option value="approved">{{ i18n.ts.notApproved }}</option>
<option value="admin">{{ i18n.ts.administrator }}</option>
<option value="moderator">{{ i18n.ts.moderator }}</option>
<option value="suspended">{{ i18n.ts.suspend }}</option>

View File

@ -45,6 +45,13 @@ function submit() {
os.api('signup-pending', {
code: props.code,
}).then(res => {
if (res.pendingApproval) {
return os.alert({
type: 'success',
title: i18n.ts._signup.almostThere,
text: i18n.ts._signup.approvalPending,
});
}
return login(res.i, '/');
}).catch(() => {
submitting = false;

View File

@ -33,6 +33,7 @@ export type Endpoints = {
'admin/suspend-user': { req: TODO; res: TODO; };
'admin/nsfw-user': { req: TODO; res: TODO; };
'admin/unnsfw-user': { req: TODO; res: TODO; };
'admin/approve-user': { req: TODO; res: TODO; };
'admin/unsilence-user': { req: TODO; res: TODO; };
'admin/unsuspend-user': { req: TODO; res: TODO; };
'admin/update-meta': { req: TODO; res: TODO; };

View File

@ -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;

View File

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