Merge pull request 'Account approval' (#17) from feature/approval into main

Reviewed-on: woem.men/forkey#17
Reviewed-by: sugar <sugar@noreply.woem.men>
This commit is contained in:
ashten 2025-01-11 20:54:21 +00:00
commit 53f0cefd04
45 changed files with 931 additions and 51 deletions

View file

@ -43,6 +43,7 @@ alreadyFavorited: "Already added to favorites."
cantFavorite: "Couldn't add to favorites." cantFavorite: "Couldn't add to favorites."
pin: "Pin to profile" pin: "Pin to profile"
unpin: "Unpin from profile" unpin: "Unpin from profile"
approvals: "Approvals"
copyContent: "Copy contents" copyContent: "Copy contents"
copyLink: "Copy link" copyLink: "Copy link"
copyLinkRenote: "Copy renote link" copyLinkRenote: "Copy renote link"
@ -148,6 +149,7 @@ unsuspend: "Unsuspend"
blockConfirm: "Are you sure that you want to block this account?" blockConfirm: "Are you sure that you want to block this account?"
unblockConfirm: "Are you sure that you want to unblock this account?" unblockConfirm: "Are you sure that you want to unblock this account?"
suspendConfirm: "Are you sure that you want to suspend this account?" 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?" unsuspendConfirm: "Are you sure that you want to unsuspend this account?"
selectList: "Select a list" selectList: "Select a list"
editList: "Edit list" editList: "Edit list"
@ -906,6 +908,7 @@ itsOff: "Disabled"
on: "On" on: "On"
off: "Off" off: "Off"
emailRequiredForSignup: "Require email address for sign-up" emailRequiredForSignup: "Require email address for sign-up"
approvalRequiredForSignup: "Require approval for new users"
unread: "Unread" unread: "Unread"
filter: "Filter" filter: "Filter"
controlPanel: "Control Panel" controlPanel: "Control Panel"
@ -966,6 +969,12 @@ requireAdminForView: "You must log in with an administrator account to view this
isSystemAccount: "An account created and automatically operated by the system." isSystemAccount: "An account created and automatically operated by the system."
typeToConfirm: "Please enter {x} to confirm" typeToConfirm: "Please enter {x} to confirm"
deleteAccount: "Delete account" deleteAccount: "Delete account"
pendingUserApprovals: "There are users awaiting approval."
approveAccount: "Approve"
denyAccount: "Deny & Delete"
approved: "Approved"
notApproved: "Not Approved"
approvalStatus: "Approval Status"
document: "Documentation" document: "Documentation"
numberOfPageCache: "Number of cached pages" 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." numberOfPageCacheDescription: "Increasing this number will improve convenience for but cause more load as more memory usage on the user's device."
@ -1061,6 +1070,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." disableFederationConfirmWarn: "Even if defederated, posts will continue to be public unless set otherwise. You usually do not need to do this."
disableFederationOk: "Disable" disableFederationOk: "Disable"
invitationRequiredToRegister: "This instance is invite-only. You must enter a valid invite code sign up." 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" emailNotSupported: "This instance does not support sending emails"
postToTheChannel: "Post to channel" postToTheChannel: "Post to channel"
cannotBeChangedLater: "This cannot be changed later." cannotBeChangedLater: "This cannot be changed later."
@ -1869,6 +1879,8 @@ _signup:
almostThere: "Almost there" almostThere: "Almost there"
emailAddressInfo: "Please enter your email address. It will not be made public." 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." 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:
accountDelete: "Delete account" 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." 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."

44
locales/index.d.ts vendored
View file

@ -620,6 +620,10 @@ export interface Locale extends ILocale {
* *
*/ */
"suspendConfirm": string; "suspendConfirm": string;
/**
*
*/
"approveConfirm": string;
/** /**
* *
*/ */
@ -3644,6 +3648,10 @@ export interface Locale extends ILocale {
* *
*/ */
"emailRequiredForSignup": string; "emailRequiredForSignup": string;
/**
*
*/
"approvalRequiredForSignup": string;
/** /**
* *
*/ */
@ -3887,6 +3895,30 @@ export interface Locale extends ILocale {
* *
*/ */
"deleteAccount": string; "deleteAccount": string;
/**
*
*/
"pendingUserApprovals": string;
/**
*
*/
"approveAccount": string;
/**
*
*/
"denyAccount": string;
/**
*
*/
"approved": string;
/**
*
*/
"notApproved": string;
/**
*
*/
"approvalStatus": string;
/** /**
* *
*/ */
@ -4267,6 +4299,10 @@ export interface Locale extends ILocale {
* *
*/ */
"invitationRequiredToRegister": string; "invitationRequiredToRegister": string;
/**
*
*/
"approvalRequiredToRegister": string;
/** /**
* *
*/ */
@ -7306,6 +7342,14 @@ export interface Locale extends ILocale {
* ({email})30 * ({email})30
*/ */
"emailSent": ParameterizedString<"email">; "emailSent": ParameterizedString<"email">;
/**
*
*/
"approvalPending": string;
/**
*
*/
"reasonInfo": string;
}; };
"_accountDelete": { "_accountDelete": {
/** /**

View file

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

@ -61,6 +61,7 @@ export class CreateSystemUserService {
isLocked: true, isLocked: true,
isExplorable: false, isExplorable: false,
isBot: true, isBot: true,
approved: true,
}).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0])); }).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0]));
await transactionalEntityManager.insert(MiUserKeypair, { await transactionalEntityManager.insert(MiUserKeypair, {

View file

@ -58,9 +58,10 @@ 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;
// Validate username // Validate username
@ -93,9 +94,9 @@ export class SignupService {
} }
const isTheFirstUser = !await this.instanceActorService.realLocalUsersPresent(); const isTheFirstUser = !await this.instanceActorService.realLocalUsersPresent();
const instance = await this.metaService.fetch(true);
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');
@ -128,6 +129,10 @@ export class SignupService {
try { try {
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 => {
const exist = await transactionalEntityManager.findOneBy(MiUser, { const exist = await transactionalEntityManager.findOneBy(MiUser, {
@ -144,6 +149,8 @@ export class SignupService {
host: host ? this.utilityService.normalizeHost(host) : null, host: host ? this.utilityService.normalizeHost(host) : null,
token: secret, token: secret,
isRoot: isTheFirstUser, isRoot: isTheFirstUser,
approved: defaultApproval,
signupReason: reason,
})); }));
await transactionalEntityManager.save(new MiUserKeypair({ await transactionalEntityManager.save(new MiUserKeypair({

View file

@ -328,8 +328,8 @@ export class ApPersonService implements OnModuleInit {
this.logger.error('error occurred while fetching following/followers collection', { error: err }); this.logger.error('error occurred while fetching following/followers collection', { error: err });
} }
return 'private'; return 'private';
}) }),
) ),
); );
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
@ -378,6 +378,7 @@ export class ApPersonService implements OnModuleInit {
alsoKnownAs: person.alsoKnownAs, alsoKnownAs: person.alsoKnownAs,
isExplorable: person.discoverable, isExplorable: person.discoverable,
username: person.preferredUsername, username: person.preferredUsername,
approved: true,
usernameLower: person.preferredUsername?.toLowerCase(), usernameLower: person.preferredUsername?.toLowerCase(),
host, host,
inbox: person.inbox, inbox: person.inbox,
@ -526,8 +527,8 @@ export class ApPersonService implements OnModuleInit {
return undefined; return undefined;
} }
return 'private'; return 'private';
}) }),
) ),
); );
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/); const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);

View file

@ -68,6 +68,7 @@ export class MetaEntityService {
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,
enableMcaptcha: instance.enableMcaptcha, enableMcaptcha: instance.enableMcaptcha,
@ -140,6 +141,7 @@ export class MetaEntityService {
globalTimeline: instance.policies.gtlAvailable, globalTimeline: instance.policies.gtlAvailable,
registration: !instance.disableRegistration, registration: !instance.disableRegistration,
emailRequiredForSignup: instance.emailRequiredForSignup, emailRequiredForSignup: instance.emailRequiredForSignup,
approvalRequiredForSignup: instance.approvalRequiredForSignup,
hCaptcha: instance.enableHcaptcha, hCaptcha: instance.enableHcaptcha,
hcaptcha: instance.enableHcaptcha, hcaptcha: instance.enableHcaptcha,
mCaptcha: instance.enableMcaptcha, mCaptcha: instance.enableMcaptcha,

View file

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

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

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

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

View file

@ -75,6 +75,10 @@ export const packedMetaLiteSchema = {
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,
@ -275,6 +279,10 @@ export const packedMetaDetailedOnlySchema = {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,
}, },
approvalRequiredForSignup: {
type: 'boolean',
optional: false, nullable: false,
},
localTimeline: { localTimeline: {
type: 'boolean', type: 'boolean',
optional: false, nullable: false, optional: false, nullable: false,

View file

@ -93,6 +93,9 @@ export class ImportFollowingProcessorService {
// skip myself // skip myself
if (target.id === job.data.user.id) return; if (target.id === job.data.user.id) return;
// skip follows to not approved accounts
if (!target.approved) return;
this.logger.info(`Follow ${target.id} ${job.data.withReplies ? 'with replies' : 'without replies'} ...`); this.logger.info(`Follow ${target.id} ${job.data.withReplies ? 'with replies' : 'without replies'} ...`);
this.queueService.createFollowJob([{ from: user, to: { id: target.id }, silent: true, withReplies: job.data.withReplies }]); this.queueService.createFollowJob([{ from: user, to: { id: target.id }, silent: true, withReplies: job.data.withReplies }]);

View file

@ -684,6 +684,7 @@ export class ActivityPubServerService {
usernameLower: request.params.user.toLowerCase(), usernameLower: request.params.user.toLowerCase(),
host: IsNull(), host: IsNull(),
isSuspended: false, isSuspended: false,
approved: true,
}); });
return await this.userInfo(request, reply, user); return await this.userInfo(request, reply, user);

View file

@ -82,6 +82,8 @@ import * as ep___admin_showUserAccountMoveLogs from './endpoints/admin/show-user
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_declineUser from "./endpoints/admin/decline-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_updateUserName from './endpoints/admin/update-user-name.js'; import * as ep___admin_updateUserName from './endpoints/admin/update-user-name.js';
@ -477,6 +479,8 @@ const $admin_showUserAccountMoveLogs: Provider = { provide: 'ep:admin/show-user-
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_declineUser: Provider = { provide: 'ep:admin/decline-user', useClass: ep___admin_declineUser.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_updateUserName: Provider = { provide: 'ep:admin/update-user-name', useClass: ep___admin_updateUserName.default }; const $admin_updateUserName: Provider = { provide: 'ep:admin/update-user-name', useClass: ep___admin_updateUserName.default };
@ -876,6 +880,8 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_showUser, $admin_showUser,
$admin_showUsers, $admin_showUsers,
$admin_suspendUser, $admin_suspendUser,
$admin_approveUser,
$admin_declineUser,
$admin_unsuspendUser, $admin_unsuspendUser,
$admin_updateMeta, $admin_updateMeta,
$admin_updateUserName, $admin_updateUserName,
@ -1269,6 +1275,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$admin_showUser, $admin_showUser,
$admin_showUsers, $admin_showUsers,
$admin_suspendUser, $admin_suspendUser,
$admin_approveUser,
$admin_unsuspendUser, $admin_unsuspendUser,
$admin_updateMeta, $admin_updateMeta,
$admin_updateUserName, $admin_updateUserName,

View file

@ -79,6 +79,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'];
@ -132,13 +134,13 @@ export class SigninApiService {
emailVerified: true, emailVerified: true,
user: { user: {
host: IsNull(), host: IsNull(),
} },
} : { } : {
user: { user: {
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
host: IsNull(), host: IsNull(),
} },
} },
}); });
const user = (profile?.user as MiLocalUser) ?? null; const user = (profile?.user as MiLocalUser) ?? null;
@ -163,6 +165,17 @@ export class SigninApiService {
}); });
} }
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 bcrypt.compare(password, profile.password!); const same = await bcrypt.compare(password, profile.password!);
@ -207,6 +220,7 @@ export class SigninApiService {
} }
if (same) { if (same) {
if (!instance.approvalRequiredForSignup && !user.approved) this.usersRepository.update(user.id, { approved: true });
logger.info('Successfully signed in with password.'); logger.info('Successfully signed in with password.');
return this.signinService.signin(request, reply, user); return this.signinService.signin(request, reply, user);
} else { } else {
@ -234,6 +248,7 @@ export class SigninApiService {
}); });
} }
if (!instance.approvalRequiredForSignup && !user.approved) this.usersRepository.update(user.id, { approved: true });
logger.info('Successfully signed in with password and two-factor token.'); logger.info('Successfully signed in with password and two-factor token.');
return this.signinService.signin(request, reply, user); return this.signinService.signin(request, reply, user);
} else if (body.credential) { } else if (body.credential) {
@ -247,6 +262,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 });
logger.info('Successfully signed in with WebAuthn authentication.'); logger.info('Successfully signed in with WebAuthn authentication.');
return this.signinService.signin(request, reply, user); return this.signinService.signin(request, reply, user);
} else { } else {

View file

@ -3,6 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { IsNull } from 'typeorm'; import { IsNull } from 'typeorm';
@ -21,8 +22,8 @@ import { bindThis } from '@/decorators.js';
import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js'; import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { SigninService } from './SigninService.js'; import { SigninService } from './SigninService.js';
import instance from './endpoints/charts/instance.js';
import type { FastifyRequest, FastifyReply } from 'fastify'; import type { FastifyRequest, FastifyReply } from 'fastify';
import { randomUUID } from 'node:crypto';
@Injectable() @Injectable()
export class SignupApiService { export class SignupApiService {
@ -65,6 +66,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;
@ -117,6 +119,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) {
@ -134,6 +137,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) {
@ -211,6 +221,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}`;
@ -233,9 +244,17 @@ export class SignupApiService {
} else { } else {
try { try {
const { account, secret } = await this.signupService.signup({ const { account, secret } = await this.signupService.signup({
username, password, host, username, password, host, reason
}); });
if (instance.approvalRequiredForSignup) {
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.');
}
}
const res = await this.userEntityService.pack(account, account, { const res = await this.userEntityService.pack(account, account, {
schema: 'MeDetailed', schema: 'MeDetailed',
includeSecrets: true, includeSecrets: true,
@ -272,6 +291,8 @@ 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 });
@ -283,6 +304,7 @@ export class SignupApiService {
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({
@ -306,6 +328,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 };
}
logger.info('Successfully created user.', { userId: account.id }); logger.info('Successfully created user.', { userId: account.id });
return this.signinService.signin(request, reply, account as MiLocalUser); return this.signinService.signin(request, reply, account as MiLocalUser);
} catch (err) { } catch (err) {

View file

@ -82,6 +82,8 @@ import * as ep___admin_showUserAccountMoveLogs from './endpoints/admin/show-user
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_declineUser from './endpoints/admin/decline-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_updateUserName from './endpoints/admin/update-user-name.js'; import * as ep___admin_updateUserName from './endpoints/admin/update-user-name.js';
@ -475,6 +477,8 @@ 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/decline-user', ep___admin_declineUser],
['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/update-user-name', ep___admin_updateUserName], ['admin/update-user-name', ep___admin_updateUserName],

View file

@ -0,0 +1,62 @@
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,
kind: 'write:admin:approve-user',
} 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

@ -0,0 +1,79 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UsedUsernamesRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { DI } from '@/di-symbols.js';
import { EmailService } from '@/core/EmailService.js';
import { DeleteAccountService } from '@/core/DeleteAccountService.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
kind: 'write:admin:decline-user',
} 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,
@Inject(DI.usedUsernamesRepository)
private usedUsernamesRepository: UsedUsernamesRepository,
private moderationLogService: ModerationLogService,
private emailService: EmailService,
private deleteAccountService: DeleteAccountService,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy({ id: ps.userId });
if (user == null || user.isDeleted) {
throw new Error('user not found or already deleted');
}
if (user.approved) {
throw new Error('user is already approved');
}
if (user.host) {
throw new Error('user is not local');
}
const profile = await this.userProfilesRepository.findOneBy({ userId: ps.userId });
if (profile?.email) {
this.emailService.sendEmail(profile.email, 'Account Declined',
'Your Account has been declined!',
'Your Account has been declined!');
}
await this.usedUsernamesRepository.delete({ username: user.username });
//Actually delete it since the last function doesnt actually delete the account
//Note: Before approval these accounts wont federate so this is totally fine.
await this.usersRepository.delete(user.id);
this.moderationLogService.log(me, 'decline', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
});
}
}

View file

@ -33,6 +33,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,
@ -552,6 +556,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,
enableMcaptcha: instance.enableMcaptcha, enableMcaptcha: instance.enableMcaptcha,

View file

@ -180,7 +180,7 @@ export const meta = {
memo: { memo: {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
} },
}, },
}, },
}, },
@ -240,6 +240,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

@ -35,7 +35,7 @@ export const paramDef = {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
offset: { type: 'integer', default: 0 }, offset: { type: 'integer', default: 0 },
sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt', '+lastActiveDate', '-lastActiveDate'] }, 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' }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'combined' },
username: { type: 'string', nullable: true, default: null }, username: { type: 'string', nullable: true, default: null },
hostname: { hostname: {
@ -64,6 +64,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
case 'available': query.where('user.isSuspended = FALSE'); break; 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 '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 'suspended': query.where('user.isSuspended = TRUE'); break;
case 'approved': query.where('user.approved = FALSE'); break;
case 'admin': { case 'admin': {
const adminIds = await this.roleService.getAdministratorIds(); const adminIds = await this.roleService.getAdministratorIds();
if (adminIds.length === 0) return []; if (adminIds.length === 0) return [];

View file

@ -65,6 +65,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 },
@ -323,6 +324,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

@ -35,6 +35,12 @@ export const meta = {
id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5', id: 'fcd2eef9-a9b2-4c4f-8624-038099e90aa5',
}, },
unapprovedUser: {
message: 'The user has not been approved yet.',
code: 'UNAPPROVED_USER',
id: '8d66f136-b3e1-48fd-92c4-30ecfd7fdb7a',
},
followeeIsYourself: { followeeIsYourself: {
message: 'Followee is yourself.', message: 'Followee is yourself.',
code: 'FOLLOWEE_IS_YOURSELF', code: 'FOLLOWEE_IS_YOURSELF',
@ -101,15 +107,25 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
// Get followee // Get followee
const followee = await this.getterService.getUser(ps.userId).catch(err => { let followee;
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); try {
followee = await this.getterService.getUser(ps.userId);
} catch (err: any) {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') {
throw new ApiError(meta.errors.noSuchUser);
}
throw err; throw err;
}); }
if ( me.isBot && followee.isBot ) { if ( me.isBot && followee.isBot ) {
throw new ApiError(meta.errors.followingAnotherBot); throw new ApiError(meta.errors.followingAnotherBot);
} }
const isLocalUser = followee.uri == null;
if (!followee.approved && isLocalUser) {
throw new ApiError(meta.errors.unapprovedUser);
}
try { try {
await this.userFollowingService.follow(follower, followee, { withReplies: ps.withReplies }); await this.userFollowingService.follow(follower, followee, { withReplies: ps.withReplies });
} catch (e) { } catch (e) {

View file

@ -54,6 +54,8 @@ export const followersVisibilities = ['public', 'followers', 'private'] as const
export const moderationLogTypes = [ export const moderationLogTypes = [
'updateServerSettings', 'updateServerSettings',
'suspend', 'suspend',
'approve',
'decline',
'unsuspend', 'unsuspend',
'updateUserName', 'updateUserName',
'updateUserNote', 'updateUserNote',
@ -111,6 +113,16 @@ export type ModerationLogPayloads = {
userUsername: string; userUsername: string;
userHost: string | null; userHost: string | null;
}; };
approve: {
userId: string;
userUsername: string;
userHost: string | null;
};
decline: {
userId: string;
userUsername: string;
userHost: string | null;
}
unsuspend: { unsuspend: {
userId: string; userId: string;
userUsername: string; userUsername: string;

View file

@ -45,6 +45,10 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</span> <span v-else-if="emailState === 'error'" style="color: var(--error)"><i class="ti ti-alert-triangle ti-fw"></i> {{ i18n.ts.error }}</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.reasonInfo" class="_button _help"><i class="ti ti-help-circle"></i></div></template>
<template #prefix><i class="ti ti-message-question"></i></template>
</MkInput>
<MkNewPassword ref="password" :label="i18n.ts.password"/> <MkNewPassword ref="password" :label="i18n.ts.password"/>
<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.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/> <MkCaptcha v-if="instance.enableMcaptcha" ref="mcaptcha" v-model="mCaptchaResponse" :class="$style.captcha" provider="mcaptcha" :sitekey="instance.mcaptchaSiteKey" :instanceUrl="instance.mcaptchaInstanceUrl"/>
@ -85,6 +89,7 @@ const props = withDefaults(defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
(ev: 'signup', user: Misskey.entities.SigninResponse): void; (ev: 'signup', user: Misskey.entities.SigninResponse): void;
(ev: 'signupEmailPending'): void; (ev: 'signupEmailPending'): void;
(ev: 'approvalPending'): void;
}>(); }>();
const host = toUnicode(config.host); const host = toUnicode(config.host);
@ -97,6 +102,7 @@ const turnstile = ref<Captcha | undefined>();
const username = ref<string>(''); const username = ref<string>('');
const password = shallowRef<InstanceType<typeof MkNewPassword> | null>(null); const password = shallowRef<InstanceType<typeof MkNewPassword> | null>(null);
const invitationCode = ref<string>(''); const invitationCode = ref<string>('');
let reason = ref<string>('');
const email = ref(''); const email = ref('');
const usernameState = ref<null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range'>(null); const usernameState = ref<null | 'wait' | 'ok' | 'unavailable' | 'error' | 'invalid-format' | 'min-range' | 'max-range'>(null);
const emailState = ref<null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:banned' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error'>(null); const emailState = ref<null | 'wait' | 'ok' | 'unavailable:used' | 'unavailable:format' | 'unavailable:disposable' | 'unavailable:banned' | 'unavailable:mx' | 'unavailable:smtp' | 'unavailable' | 'error'>(null);
@ -195,6 +201,7 @@ async function onSubmit(): Promise<void> {
password: password.value.password, password: password.value.password,
emailAddress: email.value, emailAddress: email.value,
invitationCode: invitationCode.value, invitationCode: invitationCode.value,
reason: reason.value,
'hcaptcha-response': hCaptchaResponse.value, 'hcaptcha-response': hCaptchaResponse.value,
'm-captcha-response': mCaptchaResponse.value, 'm-captcha-response': mCaptchaResponse.value,
'g-recaptcha-response': reCaptchaResponse.value, 'g-recaptcha-response': reCaptchaResponse.value,
@ -207,6 +214,13 @@ async function onSubmit(): Promise<void> {
text: i18n.tsx._signup.emailSent({ email: email.value }), text: i18n.tsx._signup.emailSent({ email: email.value }),
}); });
emit('signupEmailPending'); emit('signupEmailPending');
} else if (instance.approvalRequiredForSignup) {
os.alert({
type: 'success',
title: i18n.ts._signup.almostThere,
text: i18n.ts._signup.approvalPending,
});
emit('approvalPending');
} else { } else {
emit('signup', { id: res.id, i: res.token }); emit('signup', { id: res.id, i: res.token });

View file

@ -25,7 +25,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<XServerRules @done="isAcceptedServerRule = true" @cancel="onClose"/> <XServerRules @done="isAcceptedServerRule = true" @cancel="onClose"/>
</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>
@ -69,6 +69,10 @@ function onSignup(res: Misskey.entities.SignupResponse) {
function onSignupEmailPending() { function onSignupEmailPending() {
dialog.value?.close(); dialog.value?.close();
} }
function onApprovalPending() {
dialog.value?.close();
}
</script> </script>
<style lang="scss" module> <style lang="scss" module>

View file

@ -113,5 +113,6 @@ onUnmounted(() => {
border: solid 0.5px var(--divider); border: solid 0.5px var(--divider);
pointer-events: none; pointer-events: none;
transform-origin: center center; transform-origin: center center;
backdrop-filter: blur(10px);
} }
</style> </style>

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

@ -0,0 +1,110 @@
<!--
SPDX-FileCopyrightText: marie and other Sharkey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkFolder :expanded="false">
<template #icon><i class="ti ti-user"></i></template>
<template #label>{{ i18n.ts.user }}: {{ user.username }}</template>
<div class="_gaps_s" :class="$style.root">
<div :class="$style.items">
<div>
<div :class="$style.label">{{ i18n.ts.createdAt }}</div>
<div><MkTime :time="user.createdAt" mode="absolute"/></div>
</div>
<div v-if="email">
<div :class="$style.label">{{ i18n.ts.emailAddress }}</div>
<div>{{ email }}</div>
</div>
<div>
<div :class="$style.label">Reason</div>
<div>{{ reason }}</div>
</div>
</div>
<div :class="$style.buttons">
<MkButton inline success @click="approveAccount()">{{ i18n.ts.approveAccount }}</MkButton>
<MkButton inline danger @click="deleteAccount()">{{ i18n.ts.denyAccount }}</MkButton>
</div>
</div>
</MkFolder>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import * as Misskey from 'misskey-js';
import MkFolder from '@/components/MkFolder.vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
import * as os from '@/os.js';
import { misskeyApi } from '@/scripts/misskey-api.js';
const props = defineProps<{
user: Misskey.entities.User;
}>();
let reason = ref('');
let email = ref('');
function getReason() {
return misskeyApi('admin/show-user', {
userId: props.user.id,
}).then(info => {
reason.value = info?.signupReason;
email.value = info?.email;
});
}
getReason();
const emits = defineEmits<{
(event: 'deleted', value: string): void;
}>();
async function deleteAccount() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.deleteAccountConfirm,
});
if (confirm.canceled) return;
await os.apiWithDialog('admin/decline-user', {
userId: props.user.id,
});
emits('deleted', props.user.id);
}
async function approveAccount() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.approveConfirm,
});
if (confirm.canceled) return;
await misskeyApi('admin/approve-user', { userId: props.user.id });
emits('deleted', props.user.id);
}
</script>
<style lang="scss" module>
.root {
text-align: left;
}
.items {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-gap: 12px;
}
.label {
font-size: 0.85em;
padding: 0 0 8px 0;
user-select: none;
opacity: 0.7;
}
.buttons {
display: flex;
gap: 8px;
}
</style>

View file

@ -15,6 +15,8 @@ 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">{{ i18n.ts.notApproved }}</span>
<span v-if="approved" class="moderator">{{ i18n.ts.approved }}</span>
<span v-if="admin" class="admin">Admin</span> <span v-if="admin" class="admin">Admin</span>
<span v-if="moderator" class="moderator">Moderator</span> <span v-if="moderator" class="moderator">Moderator</span>
<span v-if="silenced" class="silenced">Silenced</span> <span v-if="silenced" class="silenced">Silenced</span>
@ -219,6 +221,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>{{ 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> </FormSuspense>
</MkSpacer> </MkSpacer>
</MkStickyContainer> </MkStickyContainer>
@ -267,10 +283,13 @@ const ap = ref<any>(null);
const admin = ref(false); const admin = ref(false);
const moderator = ref(false); const moderator = ref(false);
const silenced = ref(false); const silenced = ref(false);
const approved = ref(false);
const limited = ref(false); const limited = ref(false);
const suspended = ref(false); const suspended = ref(false);
const deleted = ref(false); const deleted = ref(false);
const moderationNote = ref(''); const moderationNote = ref('');
const signupReason = ref('');
const filesPagination = { const filesPagination = {
endpoint: 'admin/drive/files' as const, endpoint: 'admin/drive/files' as const,
limit: 10, limit: 10,
@ -303,10 +322,12 @@ function createFetcher() {
admin.value = info.value.isAdmin; admin.value = info.value.isAdmin;
moderator.value = info.value.isModerator; moderator.value = info.value.isModerator;
silenced.value = info.value.isSilenced; silenced.value = info.value.isSilenced;
approved.value = info.value.approved;
limited.value = info.value.isLimited; limited.value = info.value.isLimited;
suspended.value = info.value.isSuspended; suspended.value = info.value.isSuspended;
deleted.value = info.value.isDeleted; deleted.value = info.value.isDeleted;
moderationNote.value = info.value.moderationNote; moderationNote.value = info.value.moderationNote;
signupReason.value = info.value.signupReason;
watch(moderationNote, async () => { watch(moderationNote, async () => {
await misskeyApi('admin/update-user-note', { await misskeyApi('admin/update-user-note', {
@ -470,6 +491,21 @@ async function deleteAccount(soft: boolean) {
} }
} }
async function approveAccount() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.approveConfirm,
});
if (confirm.canceled) return;
await os.apiWithDialog('admin/approve-user', {
type: 'warning',
text: i18n.ts.approveConfirm,
userId: user.value.id,
});
await refreshUser();
}
async function assignRole() { async function assignRole() {
const roles = await misskeyApi('admin/roles/list'); const roles = await misskeyApi('admin/roles/list');
@ -573,35 +609,79 @@ watch(user, () => {
const headerActions = computed(() => []); const headerActions = computed(() => []);
const headerTabs = computed(() => [{ const headerTabs = computed(() => iAmAdmin && !approved.value ?
key: 'overview', [
title: i18n.ts.overview, {
icon: 'ti ti-info-circle', key: 'overview',
}, { title: i18n.ts.overview,
key: 'roles', icon: 'ti ti-info-circle',
title: i18n.ts.roles, },
icon: 'ti ti-badges', {
}, { key: 'roles',
key: 'announcements', title: i18n.ts.roles,
title: i18n.ts.announcements, icon: 'ti ti-badges',
icon: 'ti ti-speakerphone', },
}, { {
key: 'drive', key: 'announcements',
title: i18n.ts.drive, title: i18n.ts.announcements,
icon: 'ti ti-cloud', icon: 'ti ti-speakerphone',
}, { },
key: 'chart', {
title: i18n.ts.charts, key: 'drive',
icon: 'ti ti-chart-line', title: i18n.ts.drive,
}, { icon: 'ti ti-cloud',
key: 'activitypub', },
title: 'ActivityPub', {
icon: 'ti ti-share', key: 'chart',
}, { title: i18n.ts.charts,
key: 'raw', icon: 'ti ti-chart-line',
title: 'Raw', },
icon: 'ti ti-code', {
}]); key: 'activitypub',
title: 'ActivityPub',
icon: 'ti ti-share',
},
{
key: 'raw',
title: 'Raw',
icon: 'ti ti-code',
},
{
key: 'approval',
title: 'Approval',
icon: 'ti ti-scan-eye',
}
] : [
{
key: 'overview',
title: i18n.ts.overview,
icon: 'ti ti-info-circle',
}, {
key: 'roles',
title: i18n.ts.roles,
icon: 'ti ti-badges',
}, {
key: 'announcements',
title: i18n.ts.announcements,
icon: 'ti ti-speakerphone',
}, {
key: 'drive',
title: i18n.ts.drive,
icon: 'ti ti-cloud',
}, {
key: 'chart',
title: i18n.ts.charts,
icon: 'ti ti-chart-line',
}, {
key: 'activitypub',
title: 'ActivityPub',
icon: 'ti ti-share',
}, {
key: 'raw',
title: 'Raw',
icon: 'ti ti-code',
},
]);
definePageMetadata(() => ({ definePageMetadata(() => ({
title: user.value ? acct(user.value) : i18n.ts.userInfo, title: user.value ? acct(user.value) : i18n.ts.userInfo,
@ -769,4 +849,16 @@ definePageMetadata(() => ({
border-bottom: none; border-bottom: none;
} }
} }
.casdwq {
.silenced {
color: var(--warn);
border-color: var(--warn);
}
.moderator {
color: var(--success);
border-color: var(--success);
}
}
</style> </style>

View file

@ -0,0 +1,74 @@
<!--
SPDX-FileCopyrightText: marie and other Sharkey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="900">
<div class="_gaps_m">
<MkPagination ref="paginationComponent" :pagination="pagination" :displayLimit="50">
<template #default="{ items }">
<div class="_gaps_s">
<SkApprovalUser v-for="item in items" :key="item.id" :user="(item as any)" :onDeleted="deleted"/>
</div>
</template>
</MkPagination>
</div>
</MkSpacer>
</MkStickyContainer>
</div>
</template>
<script lang="ts" setup>
import { computed, shallowRef } from 'vue';
import XHeader from './_header_.vue';
import MkPagination from '@/components/MkPagination.vue';
import SkApprovalUser from '@/components/SkApprovalUser.vue';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
let paginationComponent = shallowRef<InstanceType<typeof MkPagination>>();
const pagination = {
endpoint: 'admin/show-users' as const,
limit: 10,
params: computed(() => ({
sort: '+createdAt',
state: 'approved',
origin: 'local',
})),
offsetMode: true,
};
function deleted(id: string) {
if (paginationComponent.value) {
paginationComponent.value.items = paginationComponent.value.items.filter(
(item: any) => item.id !== id
);
}
}
const headerActions = computed(() => []);
const headerTabs = computed(() => []);
definePageMetadata(() => ({
title: i18n.ts.approvals,
icon: 'ti ti-user-eye',
}));
</script>
<style lang="scss" module>
.inputs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.input {
flex: 1;
}
</style>

View file

@ -16,6 +16,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkInfo v-if="noMaintainerInformation" warn class="info">{{ i18n.ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> <MkInfo v-if="noMaintainerInformation" warn class="info">{{ i18n.ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
<MkInfo v-if="noBotProtection" warn class="info">{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> <MkInfo v-if="noBotProtection" warn class="info">{{ i18n.ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
<MkInfo v-if="noEmailServer" warn class="info">{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo> <MkInfo v-if="noEmailServer" warn class="info">{{ i18n.ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ i18n.ts.configure }}</MkA></MkInfo>
<MkInfo v-if="pendingUserApprovals" warn class="info">{{ i18n.ts.pendingUserApprovals }} <MkA to="/admin/approvals" class="_link">{{ i18n.ts.check }}</MkA></MkInfo>
<MkSuperMenu :def="menuDef" :grid="narrow"></MkSuperMenu> <MkSuperMenu :def="menuDef" :grid="narrow"></MkSuperMenu>
</div> </div>
@ -61,6 +62,7 @@ let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instan
let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableTurnstile; let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableTurnstile;
let noEmailServer = !instance.enableEmail; let noEmailServer = !instance.enableEmail;
const thereIsUnresolvedAbuseReport = ref(false); const thereIsUnresolvedAbuseReport = ref(false);
const pendingUserApprovals = ref(false);
const currentPage = computed(() => router.currentRef.value.child); const currentPage = computed(() => router.currentRef.value.child);
misskeyApi('admin/abuse-user-reports', { misskeyApi('admin/abuse-user-reports', {
@ -70,6 +72,14 @@ misskeyApi('admin/abuse-user-reports', {
if (reports.length > 0) thereIsUnresolvedAbuseReport.value = true; if (reports.length > 0) thereIsUnresolvedAbuseReport.value = true;
}); });
misskeyApi('admin/show-users', {
state: 'approved',
origin: 'local',
limit: 1,
}).then(approvals => {
if (approvals.length > 0) pendingUserApprovals.value = true;
});
const NARROW_THRESHOLD = 600; const NARROW_THRESHOLD = 600;
const ro = new ResizeObserver((entries, observer) => { const ro = new ResizeObserver((entries, observer) => {
if (entries.length === 0) return; if (entries.length === 0) return;
@ -107,6 +117,11 @@ const menuDef = computed(() => [{
to: '/admin/invites', to: '/admin/invites',
active: currentPage.value?.route.name === 'invites', active: currentPage.value?.route.name === 'invites',
}, { }, {
icon: 'ti ti-user-scan',
text: i18n.ts.approvals,
to: '/admin/approvals',
active: currentPage.value?.route.name === 'approvals',
}, {
icon: 'ti ti-badges', icon: 'ti ti-badges',
text: i18n.ts.roles, text: i18n.ts.roles,
to: '/admin/roles', to: '/admin/roles',

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>{{ i18n.ts.approvalRequiredForSignup }}</template>
</MkSwitch>
<FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink> <FormLink to="/admin/server-rules">{{ i18n.ts.serverRules }}</FormLink>
<MkInput v-model="tosUrl" type="url"> <MkInput v-model="tosUrl" type="url">
@ -90,6 +94,7 @@ import FormLink from '@/components/form/link.vue';
const enableRegistration = ref<boolean>(false); const enableRegistration = ref<boolean>(false);
const emailRequiredForSignup = ref<boolean>(false); const emailRequiredForSignup = ref<boolean>(false);
const approvalRequiredForSignup: boolean = ref<boolean>(false);
const sensitiveWords = ref<string>(''); const sensitiveWords = ref<string>('');
const prohibitedWords = ref<string>(''); const prohibitedWords = ref<string>('');
const hiddenTags = ref<string>(''); const hiddenTags = ref<string>('');
@ -103,6 +108,7 @@ async function init() {
const meta = await misskeyApi('admin/meta'); const meta = await misskeyApi('admin/meta');
enableRegistration.value = !meta.disableRegistration; enableRegistration.value = !meta.disableRegistration;
emailRequiredForSignup.value = meta.emailRequiredForSignup; emailRequiredForSignup.value = meta.emailRequiredForSignup;
approvalRequiredForSignup.value = meta.approvalRequiredForSignup;
sensitiveWords.value = meta.sensitiveWords.join('\n'); sensitiveWords.value = meta.sensitiveWords.join('\n');
prohibitedWords.value = meta.prohibitedWords.join('\n'); prohibitedWords.value = meta.prohibitedWords.join('\n');
hiddenTags.value = meta.hiddenTags.join('\n'); hiddenTags.value = meta.hiddenTags.join('\n');
@ -117,6 +123,7 @@ function save() {
os.apiWithDialog('admin/update-meta', { os.apiWithDialog('admin/update-meta', {
disableRegistration: !enableRegistration.value, disableRegistration: !enableRegistration.value,
emailRequiredForSignup: emailRequiredForSignup.value, emailRequiredForSignup: emailRequiredForSignup.value,
approvalRequiredForSignup: approvalRequiredForSignup.value,
tosUrl: tosUrl.value, tosUrl: tosUrl.value,
privacyPolicyUrl: privacyPolicyUrl.value, privacyPolicyUrl: privacyPolicyUrl.value,
sensitiveWords: sensitiveWords.value.split('\n'), sensitiveWords: sensitiveWords.value.split('\n'),

View file

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:class="{ :class="{
[$style.logGreen]: ['createRole', 'addCustomEmoji', 'createGlobalAnnouncement', 'createUserAnnouncement', 'createAd', 'createInvitation', 'createAvatarDecoration'].includes(log.type), [$style.logGreen]: ['createRole', 'addCustomEmoji', 'createGlobalAnnouncement', 'createUserAnnouncement', 'createAd', 'createInvitation', 'createAvatarDecoration'].includes(log.type),
[$style.logYellow]: ['markSensitiveDriveFile', 'resetPassword', 'regenerateUserToken', 'updateUserName', 'unsetUserAvatar', 'unsetUserBanner', 'unsetUserMutualLink'].includes(log.type), [$style.logYellow]: ['markSensitiveDriveFile', 'resetPassword', 'regenerateUserToken', 'updateUserName', 'unsetUserAvatar', 'unsetUserBanner', 'unsetUserMutualLink'].includes(log.type),
[$style.logRed]: ['suspend', 'deleteRole', 'suspendRemoteInstance', 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'deleteCustomEmoji', 'deleteNote', 'deleteDriveFile', 'deleteAd', 'deleteAvatarDecoration'].includes(log.type) [$style.logRed]: ['suspend', 'approve', 'deleteRole', 'suspendRemoteInstance', 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'deleteCustomEmoji', 'deleteNote', 'deleteDriveFile', 'deleteAd', 'deleteAvatarDecoration'].includes(log.type)
}" }"
>{{ i18n.ts._moderationLogTypes[log.type] }}</b> >{{ 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>
@ -19,6 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'unsetUserBanner'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span> <span v-else-if="log.type === 'unsetUserBanner'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
<span v-else-if="log.type === 'unsetUserMutualLink'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span> <span v-else-if="log.type === 'unsetUserMutualLink'">: @{{ 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 === 'regenerateUserToken'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span> <span v-else-if="log.type === 'regenerateUserToken'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
@ -79,6 +80,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

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

View file

@ -483,10 +483,14 @@ const routes: RouteDef[] = [{
name: 'invites', name: 'invites',
component: page(() => import('@/pages/admin/invites.vue')), component: page(() => import('@/pages/admin/invites.vue')),
}, { }, {
path: '/approvals',
name: 'approvals',
component: page(() => import('@/pages/admin/approvals.vue')),
}, {
path: '/', path: '/',
component: page(() => import('@/pages/_empty_.vue')), component: page(() => import('@/pages/_empty_.vue')),
}], }],
}, { },{
path: '/my/notifications', path: '/my/notifications',
component: page(() => import('@/pages/notifications.vue')), component: page(() => import('@/pages/notifications.vue')),
loginRequired: true, loginRequired: true,

View file

@ -116,6 +116,9 @@ type AdminAnnouncementsListResponse = operations['admin___announcements___list']
// @public (undocumented) // @public (undocumented)
type AdminAnnouncementsUpdateRequest = operations['admin___announcements___update']['requestBody']['content']['application/json']; type AdminAnnouncementsUpdateRequest = operations['admin___announcements___update']['requestBody']['content']['application/json'];
// @public (undocumented)
type AdminApproveUserRequest = operations['admin___approve-user']['requestBody']['content']['application/json'];
// @public (undocumented) // @public (undocumented)
type AdminAvatarDecorationsCreateRequest = operations['admin___avatar-decorations___create']['requestBody']['content']['application/json']; type AdminAvatarDecorationsCreateRequest = operations['admin___avatar-decorations___create']['requestBody']['content']['application/json'];
@ -134,6 +137,9 @@ type AdminAvatarDecorationsListResponse = operations['admin___avatar-decorations
// @public (undocumented) // @public (undocumented)
type AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json']; type AdminAvatarDecorationsUpdateRequest = operations['admin___avatar-decorations___update']['requestBody']['content']['application/json'];
// @public (undocumented)
type AdminDeclineUserRequest = operations['admin___decline-user']['requestBody']['content']['application/json'];
// @public (undocumented) // @public (undocumented)
type AdminDriveDeleteAllFilesOfAUserRequest = operations['admin___drive___delete-all-files-of-a-user']['requestBody']['content']['application/json']; type AdminDriveDeleteAllFilesOfAUserRequest = operations['admin___drive___delete-all-files-of-a-user']['requestBody']['content']['application/json'];
@ -1325,6 +1331,8 @@ declare namespace entities {
AdminShowUsersRequest, AdminShowUsersRequest,
AdminShowUsersResponse, AdminShowUsersResponse,
AdminSuspendUserRequest, AdminSuspendUserRequest,
AdminApproveUserRequest,
AdminDeclineUserRequest,
AdminUnsuspendUserRequest, AdminUnsuspendUserRequest,
AdminUpdateMetaRequest, AdminUpdateMetaRequest,
AdminUpdateUserNameRequest, AdminUpdateUserNameRequest,
@ -2544,7 +2552,7 @@ type ModerationLog = {
}); });
// @public (undocumented) // @public (undocumented)
export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "unsuspend", "updateUserName", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "regenerateUserToken", "suspendRemoteInstance", "unsuspendRemoteInstance", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd", "createIndieAuthClient", "updateIndieAuthClient", "deleteIndieAuthClient", "createSSOServiceProvider", "updateSSOServiceProvider", "deleteSSOServiceProvider", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner", "unsetUserMutualBanner"]; export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "approve", "decline", "unsuspend", "updateUserName", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "regenerateUserToken", "suspendRemoteInstance", "unsuspendRemoteInstance", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd", "createIndieAuthClient", "updateIndieAuthClient", "deleteIndieAuthClient", "createSSOServiceProvider", "updateSSOServiceProvider", "deleteSSOServiceProvider", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner", "unsetUserMutualBanner"];
// @public (undocumented) // @public (undocumented)
type MuteCreateRequest = operations['mute___create']['requestBody']['content']['application/json']; type MuteCreateRequest = operations['mute___create']['requestBody']['content']['application/json'];
@ -2796,7 +2804,7 @@ type PagesUpdateRequest = operations['pages___update']['requestBody']['content']
function parse(acct: string): Acct; function parse(acct: string): Acct;
// @public (undocumented) // @public (undocumented)
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "read:admin:abuse-report-resolvers", "write:admin:abuse-report-resolvers", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:regenerate-user-token", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-account-move-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-name", "write:admin:user-note", "write:admin:user-avatar", "write:admin:user-banner", "write:admin:user-mutual-link", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:indie-auth", "read:admin:indie-auth", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:sso", "read:admin:sso", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"]; export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "read:admin:abuse-report-resolvers", "write:admin:abuse-report-resolvers", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:regenerate-user-token", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-account-move-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:approve-user", "write:admin:decline-user", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-name", "write:admin:user-note", "write:admin:user-avatar", "write:admin:user-banner", "write:admin:user-mutual-link", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:indie-auth", "read:admin:indie-auth", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:sso", "read:admin:sso", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"];
// @public (undocumented) // @public (undocumented)
type PingResponse = operations['ping']['responses']['200']['content']['application/json']; type PingResponse = operations['ping']['responses']['200']['content']['application/json'];

View file

@ -840,6 +840,28 @@ declare module '../api.js' {
credential?: string | null, credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>; ): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:approve-user*
*/
request<E extends 'admin/approve-user', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:decline-user*
*/
request<E extends 'admin/decline-user', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/** /**
* No description provided. * No description provided.
* *

View file

@ -103,6 +103,8 @@ import type {
AdminShowUsersRequest, AdminShowUsersRequest,
AdminShowUsersResponse, AdminShowUsersResponse,
AdminSuspendUserRequest, AdminSuspendUserRequest,
AdminApproveUserRequest,
AdminDeclineUserRequest,
AdminUnsuspendUserRequest, AdminUnsuspendUserRequest,
AdminUpdateMetaRequest, AdminUpdateMetaRequest,
AdminUpdateUserNameRequest, AdminUpdateUserNameRequest,
@ -667,6 +669,8 @@ export type Endpoints = {
'admin/show-user': { req: AdminShowUserRequest; res: AdminShowUserResponse }; 'admin/show-user': { req: AdminShowUserRequest; res: AdminShowUserResponse };
'admin/show-users': { req: AdminShowUsersRequest; res: AdminShowUsersResponse }; 'admin/show-users': { req: AdminShowUsersRequest; res: AdminShowUsersResponse };
'admin/suspend-user': { req: AdminSuspendUserRequest; res: EmptyResponse }; 'admin/suspend-user': { req: AdminSuspendUserRequest; res: EmptyResponse };
'admin/approve-user': { req: AdminApproveUserRequest; res: EmptyResponse };
'admin/decline-user': { req: AdminDeclineUserRequest; res: EmptyResponse };
'admin/unsuspend-user': { req: AdminUnsuspendUserRequest; res: EmptyResponse }; 'admin/unsuspend-user': { req: AdminUnsuspendUserRequest; res: EmptyResponse };
'admin/update-meta': { req: AdminUpdateMetaRequest; res: EmptyResponse }; 'admin/update-meta': { req: AdminUpdateMetaRequest; res: EmptyResponse };
'admin/update-user-name': { req: AdminUpdateUserNameRequest; res: EmptyResponse }; 'admin/update-user-name': { req: AdminUpdateUserNameRequest; res: EmptyResponse };

View file

@ -106,6 +106,8 @@ export type AdminShowUserResponse = operations['admin___show-user']['responses']
export type AdminShowUsersRequest = operations['admin___show-users']['requestBody']['content']['application/json']; export type AdminShowUsersRequest = operations['admin___show-users']['requestBody']['content']['application/json'];
export type AdminShowUsersResponse = operations['admin___show-users']['responses']['200']['content']['application/json']; export type AdminShowUsersResponse = operations['admin___show-users']['responses']['200']['content']['application/json'];
export type AdminSuspendUserRequest = operations['admin___suspend-user']['requestBody']['content']['application/json']; export type AdminSuspendUserRequest = operations['admin___suspend-user']['requestBody']['content']['application/json'];
export type AdminApproveUserRequest = operations['admin___approve-user']['requestBody']['content']['application/json'];
export type AdminDeclineUserRequest = operations['admin___decline-user']['requestBody']['content']['application/json'];
export type AdminUnsuspendUserRequest = operations['admin___unsuspend-user']['requestBody']['content']['application/json']; export type AdminUnsuspendUserRequest = operations['admin___unsuspend-user']['requestBody']['content']['application/json'];
export type AdminUpdateMetaRequest = operations['admin___update-meta']['requestBody']['content']['application/json']; export type AdminUpdateMetaRequest = operations['admin___update-meta']['requestBody']['content']['application/json'];
export type AdminUpdateUserNameRequest = operations['admin___update-user-name']['requestBody']['content']['application/json']; export type AdminUpdateUserNameRequest = operations['admin___update-user-name']['requestBody']['content']['application/json'];

View file

@ -697,6 +697,24 @@ export type paths = {
*/ */
post: operations['admin___suspend-user']; post: operations['admin___suspend-user'];
}; };
'/admin/approve-user': {
/**
* admin/approve-user
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:approve-user*
*/
post: operations['admin___approve-user'];
};
'/admin/decline-user': {
/**
* admin/decline-user
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:decline-user*
*/
post: operations['admin___decline-user'];
};
'/admin/unsuspend-user': { '/admin/unsuspend-user': {
/** /**
* admin/unsuspend-user * admin/unsuspend-user
@ -5111,6 +5129,7 @@ export type components = {
defaultLightTheme: string | null; defaultLightTheme: string | null;
disableRegistration: boolean; disableRegistration: boolean;
emailRequiredForSignup: boolean; emailRequiredForSignup: boolean;
approvalRequiredForSignup: boolean;
enableHcaptcha: boolean; enableHcaptcha: boolean;
hcaptchaSiteKey: string | null; hcaptchaSiteKey: string | null;
enableMcaptcha: boolean; enableMcaptcha: boolean;
@ -5166,6 +5185,7 @@ export type components = {
features?: { features?: {
registration: boolean; registration: boolean;
emailRequiredForSignup: boolean; emailRequiredForSignup: boolean;
approvalRequiredForSignup: boolean;
localTimeline: boolean; localTimeline: boolean;
globalTimeline: boolean; globalTimeline: boolean;
hCaptcha: boolean; hCaptcha: boolean;
@ -5258,6 +5278,7 @@ export type operations = {
cacheRemoteFiles: boolean; cacheRemoteFiles: boolean;
cacheRemoteSensitiveFiles: boolean; cacheRemoteSensitiveFiles: boolean;
emailRequiredForSignup: boolean; emailRequiredForSignup: boolean;
approvalRequiredForSignup: boolean;
enableHcaptcha: boolean; enableHcaptcha: boolean;
hcaptchaSiteKey: string | null; hcaptchaSiteKey: string | null;
enableMcaptcha: boolean; enableMcaptcha: boolean;
@ -9887,7 +9908,7 @@ export type operations = {
* @default all * @default all
* @enum {string} * @enum {string}
*/ */
state?: 'all' | 'alive' | 'available' | 'admin' | 'moderator' | 'adminOrModerator' | 'suspended'; state?: 'all' | 'alive' | 'available' | 'admin' | 'moderator' | 'adminOrModerator' | 'suspended' | 'approved';
/** /**
* @default combined * @default combined
* @enum {string} * @enum {string}
@ -9994,6 +10015,110 @@ export type operations = {
}; };
}; };
}; };
/**
* admin/approve-user
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:approve-user*
*/
'admin___approve-user': {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
userId: string;
};
};
};
responses: {
/** @description OK (without any results) */
204: {
content: never;
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* admin/decline-user
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:admin:decline-user*
*/
'admin___decline-user': {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
userId: string;
};
};
};
responses: {
/** @description OK (without any results) */
204: {
content: never;
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/** /**
* admin/unsuspend-user * admin/unsuspend-user
* @description No description provided. * @description No description provided.
@ -10081,6 +10206,7 @@ export type operations = {
cacheRemoteFiles?: boolean; cacheRemoteFiles?: boolean;
cacheRemoteSensitiveFiles?: boolean; cacheRemoteSensitiveFiles?: boolean;
emailRequiredForSignup?: boolean; emailRequiredForSignup?: boolean;
approvalRequiredForSignup?: boolean;
enableHcaptcha?: boolean; enableHcaptcha?: boolean;
hcaptchaSiteKey?: string | null; hcaptchaSiteKey?: string | null;
hcaptchaSecretKey?: string | null; hcaptchaSecretKey?: string | null;

View file

@ -62,6 +62,8 @@ export const permissions = [
'read:admin:show-user', 'read:admin:show-user',
'read:admin:show-users', 'read:admin:show-users',
'write:admin:suspend-user', 'write:admin:suspend-user',
'write:admin:approve-user',
'write:admin:decline-user',
'write:admin:unsuspend-user', 'write:admin:unsuspend-user',
'write:admin:meta', 'write:admin:meta',
'write:admin:user-name', 'write:admin:user-name',
@ -106,6 +108,8 @@ export const permissions = [
export const moderationLogTypes = [ export const moderationLogTypes = [
'updateServerSettings', 'updateServerSettings',
'suspend', 'suspend',
'approve',
'decline',
'unsuspend', 'unsuspend',
'updateUserName', 'updateUserName',
'updateUserNote', 'updateUserNote',
@ -163,6 +167,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;