From d84796588c1472334ddaf696a817f015c245ce44 Mon Sep 17 00:00:00 2001
From: okayurisotto <okayurisotto@proton.me>
Date: Sat, 8 Jul 2023 07:08:16 +0900
Subject: cleanup: trim trailing whitespace (#11136)

* cleanup: trim trailing whitespace

* update(`.editorconfig`)

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
---
 packages/frontend/src/pages/admin/roles.editor.vue | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

(limited to 'packages/frontend/src/pages/admin/roles.editor.vue')

diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index a1fa9d2932..02a2d4366a 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -210,7 +210,7 @@
 					</MkRange>
 				</div>
 			</MkFolder>
-			
+
 			<MkFolder v-if="matchQuery([i18n.ts._role._options.driveCapacity, 'driveCapacityMb'])">
 				<template #label>{{ i18n.ts._role._options.driveCapacity }}</template>
 				<template #suffix>
-- 
cgit v1.2.3-freya


From 02957a1b5daaaf821ce21c11cc47cf169c4fc535 Mon Sep 17 00:00:00 2001
From: yukineko <27853966+hideki0403@users.noreply.github.com>
Date: Sat, 15 Jul 2023 09:57:58 +0900
Subject: enhance: 招待機能の改善 (#11195)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* refactor(backend): 招待機能を改修

* feat(backend): 招待コードのcreate/delete/listエンドポイントを追加

* add(misskey-js): エンドポイントと型を追加

* change(backend): metaでinvite関連の情報も返すように

* add(misskey-js): エンドポイントと型を追加

* add(backend): `/endpoints/invite/limit`を追加

* fix: createdByがnullableではなかったのを修正

* fix: relationが取得できていなかった問題を修正

* fix: パラメータを間違えていたのを修正

* feat(client): 招待ページを実装

* change(client): インスタンスメニューの「招待」押した場合に招待ページに飛ぶように変更

* feat: 招待コードをコピーできるように

* change(backend): metaに招待コード発行に関する情報を持たせるのをやめる

* feat: ロールごとに招待コードの発行上限数などを設定できるように

* change(client): 招待コードをコピーしたときにダイアログを出すように

* add: 招待に関する管理者用のエンドポイントを追加

* change(backend): モデレーターであれば作成者以外でも招待コードを削除できるように

* change(backend): admin/invite/listはオフセットでページネーションするように

* feat(client): 招待コードの管理ページを追加

* feat(client): 招待コードのリストをソートできるように

* change: `admin/invite/create`のレスポンスを修正

* fix(client): 有効期限を指定できていなかった問題を修正

* refactor: 必要のない箇所を削除

* perf(backend): use limit() instead of take()

* change(client): 作成ボタンを見た目を変更

* refactor: 招待コードの生成部分を共通化し、コード内に"01OI"のいずれかの文字を含まないように

* fix(client): paginationの仕様が変わっていたので修正

* change(backend): expiresAtパラメータのnullを許容

* change(client): 有効期限を設けないときは日付の入力欄を非表示に

* fix: 自身のポリシーよりもインスタンス側のポリシーが優先表示される問題を修正

* fix: n時間のときに「n時間間」となってしまうのを修正

* fix(backend): ポリシーが途中で変更されたときに作成可能数がマイナス表記になってしまうのを修正

* change(client): 招待コードのユーザー名が不明な理由を表示するように

* update: CHANGELOG.md

* lint

* refactor

* refactor

* tweak ui

* :art:

* :art:

* add(backend): indexを追加

* change(backend): indexの追加に伴う変更

* change(client): インスタンスメニューの「招待」の場所を変更

* add(frontend): MkInviteCode用のstorybookを追加

* Update misskey-js.api.md

* fix(misskey-js): InviteのcreatedByの型が間違っていたのを修正

---------

Co-authored-by: syuilo <Syuilotan@yahoo.co.jp>
Co-authored-by: tamaina <tamaina@hotmail.co.jp>
---
 CHANGELOG.md                                       |   4 +
 locales/index.d.ts                                 |  20 ++++
 locales/ja-JP.yml                                  |  20 ++++
 .../1688720440658-refactor-invite-system.js        |  25 ++++
 .../1688880985544-add-index-to-relations.js        |  13 +++
 packages/backend/src/core/CoreModule.ts            |   6 +
 packages/backend/src/core/RoleService.ts           |   9 ++
 .../src/core/entities/InviteCodeEntityService.ts   |  52 +++++++++
 packages/backend/src/misc/generate-invite-code.ts  |  20 ++++
 packages/backend/src/misc/json-schema.ts           |   2 +
 .../src/models/entities/RegistrationTicket.ts      |  51 ++++++++-
 .../backend/src/models/json-schema/invite-code.ts  |  45 ++++++++
 packages/backend/src/server/api/EndpointsModule.ts |  28 ++++-
 .../backend/src/server/api/SignupApiService.ts     |  44 ++++++-
 packages/backend/src/server/api/endpoints.ts       |  14 ++-
 .../server/api/endpoints/admin/invite/create.ts    |  80 +++++++++++++
 .../src/server/api/endpoints/admin/invite/list.ts  |  70 ++++++++++++
 .../backend/src/server/api/endpoints/invite.ts     |  60 ----------
 .../src/server/api/endpoints/invite/create.ts      |  82 ++++++++++++++
 .../src/server/api/endpoints/invite/delete.ts      |  71 ++++++++++++
 .../src/server/api/endpoints/invite/limit.ts       |  54 +++++++++
 .../src/server/api/endpoints/invite/list.ts        |  58 ++++++++++
 packages/frontend/.storybook/fakes.ts              |  24 ++++
 packages/frontend/.storybook/generate.tsx          |   1 +
 .../src/components/MkInviteCode.stories.impl.ts    |  60 ++++++++++
 packages/frontend/src/components/MkInviteCode.vue  | 123 ++++++++++++++++++++
 packages/frontend/src/const.ts                     |   3 +
 packages/frontend/src/pages/admin/index.vue        |  11 +-
 packages/frontend/src/pages/admin/invites.vue      | 126 +++++++++++++++++++++
 packages/frontend/src/pages/admin/roles.editor.vue |  59 ++++++++++
 packages/frontend/src/pages/admin/roles.vue        |  23 ++++
 packages/frontend/src/pages/invite.vue             | 114 +++++++++++++++++++
 packages/frontend/src/router.ts                    |   8 ++
 packages/frontend/src/ui/_common_/common.ts        |  25 ++--
 packages/misskey-js/etc/misskey-js.api.md          |  51 ++++++++-
 packages/misskey-js/src/api.types.ts               |  10 +-
 packages/misskey-js/src/entities.ts                |  15 +++
 37 files changed, 1383 insertions(+), 98 deletions(-)
 create mode 100644 packages/backend/migration/1688720440658-refactor-invite-system.js
 create mode 100644 packages/backend/migration/1688880985544-add-index-to-relations.js
 create mode 100644 packages/backend/src/core/entities/InviteCodeEntityService.ts
 create mode 100644 packages/backend/src/misc/generate-invite-code.ts
 create mode 100644 packages/backend/src/models/json-schema/invite-code.ts
 create mode 100644 packages/backend/src/server/api/endpoints/admin/invite/create.ts
 create mode 100644 packages/backend/src/server/api/endpoints/admin/invite/list.ts
 delete mode 100644 packages/backend/src/server/api/endpoints/invite.ts
 create mode 100644 packages/backend/src/server/api/endpoints/invite/create.ts
 create mode 100644 packages/backend/src/server/api/endpoints/invite/delete.ts
 create mode 100644 packages/backend/src/server/api/endpoints/invite/limit.ts
 create mode 100644 packages/backend/src/server/api/endpoints/invite/list.ts
 create mode 100644 packages/frontend/src/components/MkInviteCode.stories.impl.ts
 create mode 100644 packages/frontend/src/components/MkInviteCode.vue
 create mode 100644 packages/frontend/src/pages/admin/invites.vue
 create mode 100644 packages/frontend/src/pages/invite.vue

(limited to 'packages/frontend/src/pages/admin/roles.editor.vue')

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3ef7eab90a..19e5155fb6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,10 @@
 ### General
 - identicon生成を無効にしてパフォーマンスを向上させることができるようになりました
 - サーバーのマシン情報の公開を無効にしてパフォーマンスを向上させることができるようになりました
+- 招待機能を改善しました
+  * 過去に発行した招待コードを確認できるようになりました  
+  * ロールごとに招待コードの発行数制限と制限対象期間、有効期限を設定できるようになりました  
+  * 招待コードを作成したユーザーと使用したユーザーを確認できるようになりました  
 
 ### Client
 - deck UIのカラムのメニューからアンテナとリストの編集画面を開けるように
diff --git a/locales/index.d.ts b/locales/index.d.ts
index 7555984b24..e3ad4ed003 100644
--- a/locales/index.d.ts
+++ b/locales/index.d.ts
@@ -1075,6 +1075,23 @@ export interface Locale {
     "enableServerMachineStats": string;
     "enableIdenticonGeneration": string;
     "turnOffToImprovePerformance": string;
+    "createInviteCode": string;
+    "createWithOptions": string;
+    "createCount": string;
+    "inviteCodeCreated": string;
+    "inviteLimitExceeded": string;
+    "createLimitRemaining": string;
+    "inviteLimitResetCycle": string;
+    "expirationDate": string;
+    "noExpirationDate": string;
+    "inviteCodeUsedAt": string;
+    "registeredUserUsingInviteCode": string;
+    "waitingForMailAuth": string;
+    "inviteCodeCreator": string;
+    "usedAt": string;
+    "unused": string;
+    "used": string;
+    "expired": string;
     "_initialAccountSetting": {
         "accountCreated": string;
         "letsStartAccountSetup": string;
@@ -1465,6 +1482,9 @@ export interface Locale {
             "ltlAvailable": string;
             "canPublicNote": string;
             "canInvite": string;
+            "inviteLimit": string;
+            "inviteLimitCycle": string;
+            "inviteExpirationTime": string;
             "canManageCustomEmojis": string;
             "driveCapacity": string;
             "alwaysMarkNsfw": string;
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 82efc8a469..c66b42284d 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1072,6 +1072,23 @@ branding: "ブランディング"
 enableServerMachineStats: "サーバーのマシン情報を公開する"
 enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする"
 turnOffToImprovePerformance: "オフにするとパフォーマンスが向上します。"
+createInviteCode: "招待コードを作成"
+createWithOptions: "オプションを指定して作成"
+createCount: "作成数"
+inviteCodeCreated: "招待コードを作成しました"
+inviteLimitExceeded: "作成できる招待コードの数が上限に達しています。"
+createLimitRemaining: "作成できる招待コード: 残り {limit} 個"
+inviteLimitResetCycle: "{time}で最大 {limit} 個の招待コードを作成できます。"
+expirationDate: "有効期限"
+noExpirationDate: "有効期限を設けない"
+inviteCodeUsedAt: "招待コードが使用された日時"
+registeredUserUsingInviteCode: "招待コードを使用したユーザー"
+waitingForMailAuth: "メール認証待ち"
+inviteCodeCreator: "招待コードを作成したユーザー"
+usedAt: "使用日時"
+unused: "未使用"
+used: "使用済み"
+expired: "期限切れ"
 
 _initialAccountSetting:
   accountCreated: "アカウントの作成が完了しました！"
@@ -1387,6 +1404,9 @@ _role:
     ltlAvailable: "ローカルタイムラインの閲覧"
     canPublicNote: "パブリック投稿の許可"
     canInvite: "サーバー招待コードの発行"
+    inviteLimit: "招待コードの作成可能数"
+    inviteLimitCycle: "招待コードの発行間隔"
+    inviteExpirationTime: "招待コードの有効期限"
     canManageCustomEmojis: "カスタム絵文字の管理"
     driveCapacity: "ドライブ容量"
     alwaysMarkNsfw: "ファイルにNSFWを常に付与"
diff --git a/packages/backend/migration/1688720440658-refactor-invite-system.js b/packages/backend/migration/1688720440658-refactor-invite-system.js
new file mode 100644
index 0000000000..0dd49f7027
--- /dev/null
+++ b/packages/backend/migration/1688720440658-refactor-invite-system.js
@@ -0,0 +1,25 @@
+export class RefactorInviteSystem1688720440658 {
+    name = 'RefactorInviteSystem1688720440658'
+
+    async up(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "expiresAt" TIMESTAMP WITH TIME ZONE`);
+        await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "usedAt" TIMESTAMP WITH TIME ZONE`);
+        await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "pendingUserId" character varying(32)`);
+        await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "createdById" character varying(32)`);
+        await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "usedById" character varying(32)`);
+        await queryRunner.query(`ALTER TABLE "registration_ticket" ADD CONSTRAINT "UQ_b6f93f2f30bdbb9a5ebdc7c7189" UNIQUE ("usedById")`);
+        await queryRunner.query(`ALTER TABLE "registration_ticket" ADD CONSTRAINT "FK_beba993576db0261a15364ea96e" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+        await queryRunner.query(`ALTER TABLE "registration_ticket" ADD CONSTRAINT "FK_b6f93f2f30bdbb9a5ebdc7c7189" FOREIGN KEY ("usedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`ALTER TABLE "registration_ticket" DROP CONSTRAINT "FK_b6f93f2f30bdbb9a5ebdc7c7189"`);
+        await queryRunner.query(`ALTER TABLE "registration_ticket" DROP CONSTRAINT "FK_beba993576db0261a15364ea96e"`);
+        await queryRunner.query(`ALTER TABLE "registration_ticket" DROP CONSTRAINT "UQ_b6f93f2f30bdbb9a5ebdc7c7189"`);
+        await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "usedById"`);
+        await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "createdById"`);
+        await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "pendingUserId"`);
+        await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "usedAt"`);
+        await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "expiresAt"`);
+    }
+}
diff --git a/packages/backend/migration/1688880985544-add-index-to-relations.js b/packages/backend/migration/1688880985544-add-index-to-relations.js
new file mode 100644
index 0000000000..d6b5c57f55
--- /dev/null
+++ b/packages/backend/migration/1688880985544-add-index-to-relations.js
@@ -0,0 +1,13 @@
+export class AddIndexToRelations1688880985544 {
+    name = 'AddIndexToRelations1688880985544'
+
+    async up(queryRunner) {
+        await queryRunner.query(`CREATE INDEX "IDX_beba993576db0261a15364ea96" ON "registration_ticket" ("createdById") `);
+        await queryRunner.query(`CREATE INDEX "IDX_b6f93f2f30bdbb9a5ebdc7c718" ON "registration_ticket" ("usedById") `);
+    }
+
+    async down(queryRunner) {
+        await queryRunner.query(`DROP INDEX "public"."IDX_b6f93f2f30bdbb9a5ebdc7c718"`);
+        await queryRunner.query(`DROP INDEX "public"."IDX_beba993576db0261a15364ea96"`);
+    }
+}
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index d3a1b1b024..c7c98b3bdd 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -81,6 +81,7 @@ import { GalleryLikeEntityService } from './entities/GalleryLikeEntityService.js
 import { GalleryPostEntityService } from './entities/GalleryPostEntityService.js';
 import { HashtagEntityService } from './entities/HashtagEntityService.js';
 import { InstanceEntityService } from './entities/InstanceEntityService.js';
+import { InviteCodeEntityService } from './entities/InviteCodeEntityService.js';
 import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js';
 import { MutingEntityService } from './entities/MutingEntityService.js';
 import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService.js';
@@ -205,6 +206,7 @@ const $GalleryLikeEntityService: Provider = { provide: 'GalleryLikeEntityService
 const $GalleryPostEntityService: Provider = { provide: 'GalleryPostEntityService', useExisting: GalleryPostEntityService };
 const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useExisting: HashtagEntityService };
 const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useExisting: InstanceEntityService };
+const $InviteCodeEntityService: Provider = { provide: 'InviteCodeEntityService', useExisting: InviteCodeEntityService };
 const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useExisting: ModerationLogEntityService };
 const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService };
 const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityService', useExisting: RenoteMutingEntityService };
@@ -329,6 +331,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		GalleryPostEntityService,
 		HashtagEntityService,
 		InstanceEntityService,
+		InviteCodeEntityService,
 		ModerationLogEntityService,
 		MutingEntityService,
 		RenoteMutingEntityService,
@@ -448,6 +451,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$GalleryPostEntityService,
 		$HashtagEntityService,
 		$InstanceEntityService,
+		$InviteCodeEntityService,
 		$ModerationLogEntityService,
 		$MutingEntityService,
 		$RenoteMutingEntityService,
@@ -567,6 +571,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		GalleryPostEntityService,
 		HashtagEntityService,
 		InstanceEntityService,
+		InviteCodeEntityService,
 		ModerationLogEntityService,
 		MutingEntityService,
 		RenoteMutingEntityService,
@@ -685,6 +690,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
 		$GalleryPostEntityService,
 		$HashtagEntityService,
 		$InstanceEntityService,
+		$InviteCodeEntityService,
 		$ModerationLogEntityService,
 		$MutingEntityService,
 		$RenoteMutingEntityService,
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index b0bfb44dc2..3b501cf8d7 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -21,6 +21,9 @@ export type RolePolicies = {
 	ltlAvailable: boolean;
 	canPublicNote: boolean;
 	canInvite: boolean;
+	inviteLimit: number;
+	inviteLimitCycle: number;
+	inviteExpirationTime: number;
 	canManageCustomEmojis: boolean;
 	canSearchNotes: boolean;
 	canHideAds: boolean;
@@ -42,6 +45,9 @@ export const DEFAULT_POLICIES: RolePolicies = {
 	ltlAvailable: true,
 	canPublicNote: true,
 	canInvite: false,
+	inviteLimit: 0,
+	inviteLimitCycle: 60 * 24 * 7,
+	inviteExpirationTime: 0,
 	canManageCustomEmojis: false,
 	canSearchNotes: false,
 	canHideAds: false,
@@ -277,6 +283,9 @@ export class RoleService implements OnApplicationShutdown {
 			ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
 			canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
 			canInvite: calc('canInvite', vs => vs.some(v => v === true)),
+			inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
+			inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),
+			inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)),
 			canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)),
 			canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
 			canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
diff --git a/packages/backend/src/core/entities/InviteCodeEntityService.ts b/packages/backend/src/core/entities/InviteCodeEntityService.ts
new file mode 100644
index 0000000000..2d8e7a4681
--- /dev/null
+++ b/packages/backend/src/core/entities/InviteCodeEntityService.ts
@@ -0,0 +1,52 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { DI } from '@/di-symbols.js';
+import type { RegistrationTicketsRepository } from '@/models/index.js';
+import { awaitAll } from '@/misc/prelude/await-all.js';
+import type { Packed } from '@/misc/json-schema.js';
+import type { User } from '@/models/entities/User.js';
+import type { RegistrationTicket } from '@/models/entities/RegistrationTicket.js';
+import { bindThis } from '@/decorators.js';
+import { UserEntityService } from './UserEntityService.js';
+
+@Injectable()
+export class InviteCodeEntityService {
+	constructor(
+		@Inject(DI.registrationTicketsRepository)
+		private registrationTicketsRepository: RegistrationTicketsRepository,
+
+		private userEntityService: UserEntityService,
+	) {
+	}
+
+	@bindThis
+	public async pack(
+		src: RegistrationTicket['id'] | RegistrationTicket,
+		me?: { id: User['id'] } | null | undefined,
+	): Promise<Packed<'InviteCode'>> {
+		const target = typeof src === 'object' ? src : await this.registrationTicketsRepository.findOneOrFail({
+			where: {
+				id: src,
+			},
+			relations: ['createdBy', 'usedBy'],
+		});
+
+		return await awaitAll({
+			id: target.id,
+			code: target.code,
+			expiresAt: target.expiresAt ? target.expiresAt.toISOString() : null,
+			createdAt: target.createdAt.toISOString(),
+			createdBy: target.createdBy ? await this.userEntityService.pack(target.createdBy, me) : null,
+			usedBy: target.usedBy ? await this.userEntityService.pack(target.usedBy, me) : null,
+			usedAt: target.usedAt ? target.usedAt.toISOString() : null,
+			used: !!target.usedAt,
+		});
+	}
+
+	@bindThis
+	public packMany(
+		targets: any[],
+		me: { id: User['id'] },
+	) {
+		return Promise.all(targets.map(x => this.pack(x, me)));
+	}
+}
diff --git a/packages/backend/src/misc/generate-invite-code.ts b/packages/backend/src/misc/generate-invite-code.ts
new file mode 100644
index 0000000000..617b27361d
--- /dev/null
+++ b/packages/backend/src/misc/generate-invite-code.ts
@@ -0,0 +1,20 @@
+import { secureRndstr } from './secure-rndstr.js';
+
+const CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; // [0-9A-Z] w/o [01IO] (32 patterns)
+
+export function generateInviteCode(): string {
+	const code = secureRndstr(8, {
+		chars: CHARS,
+	});
+
+	const uniqueId = [];
+	let n = Math.floor(Date.now() / 1000 / 60);
+	while (true) {
+		uniqueId.push(CHARS[n % CHARS.length]);
+		const t = Math.floor(n / CHARS.length);
+		if (!t) break;
+		n = t;
+	}
+
+	return code + uniqueId.reverse().join('');
+}
diff --git a/packages/backend/src/misc/json-schema.ts b/packages/backend/src/misc/json-schema.ts
index 7579040c68..ec6bc4a5fb 100644
--- a/packages/backend/src/misc/json-schema.ts
+++ b/packages/backend/src/misc/json-schema.ts
@@ -19,6 +19,7 @@ import { packedRenoteMutingSchema } from '@/models/json-schema/renote-muting.js'
 import { packedBlockingSchema } from '@/models/json-schema/blocking.js';
 import { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js';
 import { packedHashtagSchema } from '@/models/json-schema/hashtag.js';
+import { packedInviteCodeSchema } from '@/models/json-schema/invite-code.js';
 import { packedPageSchema } from '@/models/json-schema/page.js';
 import { packedNoteFavoriteSchema } from '@/models/json-schema/note-favorite.js';
 import { packedChannelSchema } from '@/models/json-schema/channel.js';
@@ -52,6 +53,7 @@ export const refs = {
 	RenoteMuting: packedRenoteMutingSchema,
 	Blocking: packedBlockingSchema,
 	Hashtag: packedHashtagSchema,
+	InviteCode: packedInviteCodeSchema,
 	Page: packedPageSchema,
 	Channel: packedChannelSchema,
 	QueueCount: packedQueueCountSchema,
diff --git a/packages/backend/src/models/entities/RegistrationTicket.ts b/packages/backend/src/models/entities/RegistrationTicket.ts
index 139e40f85e..4c42b20be8 100644
--- a/packages/backend/src/models/entities/RegistrationTicket.ts
+++ b/packages/backend/src/models/entities/RegistrationTicket.ts
@@ -1,17 +1,60 @@
-import { PrimaryColumn, Entity, Index, Column } from 'typeorm';
+import { PrimaryColumn, Entity, Index, Column, ManyToOne, JoinColumn, OneToOne } from 'typeorm';
 import { id } from '../id.js';
+import { User } from './User.js';
 
 @Entity()
 export class RegistrationTicket {
 	@PrimaryColumn(id())
 	public id: string;
 
-	@Column('timestamp with time zone')
-	public createdAt: Date;
-
 	@Index({ unique: true })
 	@Column('varchar', {
 		length: 64,
 	})
 	public code: string;
+
+	@Column('timestamp with time zone', {
+		nullable: true,
+	})
+	public expiresAt: Date | null;
+
+	@Column('timestamp with time zone')
+	public createdAt: Date;
+
+	@ManyToOne(type => User, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public createdBy: User | null;
+
+	@Index()
+	@Column({
+		...id(),
+		nullable: true,
+	})
+	public createdById: User['id'] | null;
+
+	@OneToOne(type => User, {
+		onDelete: 'CASCADE',
+	})
+	@JoinColumn()
+	public usedBy: User | null;
+
+	@Index()
+	@Column({
+		...id(),
+		nullable: true,
+	})
+	public usedById: User['id'] | null;
+
+	@Column('timestamp with time zone', {
+		nullable: true,
+	})
+	public usedAt: Date | null;
+
+	@Column('varchar', {
+		length: 32,
+		nullable: true,
+	})
+	public pendingUserId: string | null;
 }
diff --git a/packages/backend/src/models/json-schema/invite-code.ts b/packages/backend/src/models/json-schema/invite-code.ts
new file mode 100644
index 0000000000..b70a779f29
--- /dev/null
+++ b/packages/backend/src/models/json-schema/invite-code.ts
@@ -0,0 +1,45 @@
+export const packedInviteCodeSchema = {
+	type: 'object',
+	properties: {
+		id: {
+			type: 'string',
+			optional: false, nullable: false,
+			format: 'id',
+			example: 'xxxxxxxxxx',
+		},
+		code: {
+			type: 'string',
+			optional: false, nullable: false,
+			example: 'GR6S02ERUA5VR',
+		},
+		expiresAt: {
+			type: 'string',
+			optional: false, nullable: true,
+			format: 'date-time',
+		},
+		createdAt: {
+			type: 'string',
+			optional: false, nullable: false,
+			format: 'date-time',
+		},
+		createdBy: {
+			type: 'object',
+			optional: false, nullable: true,
+			ref: 'UserLite',
+		},
+		usedBy: {
+			type: 'object',
+			optional: false, nullable: true,
+			ref: 'UserLite',
+		},
+		usedAt: {
+			type: 'string',
+			optional: false, nullable: true,
+			format: 'date-time',
+		},
+		used: {
+			type: 'boolean',
+			optional: false, nullable: false,
+		},
+	},
+} as const;
diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts
index d1ff3fe925..4e6bc46e67 100644
--- a/packages/backend/src/server/api/EndpointsModule.ts
+++ b/packages/backend/src/server/api/EndpointsModule.ts
@@ -38,7 +38,8 @@ import * as ep___admin_federation_updateInstance from './endpoints/admin/federat
 import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js';
 import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js';
 import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js';
-import * as ep___invite from './endpoints/invite.js';
+import * as ep___admin_invite_create from './endpoints/admin/invite/create.js';
+import * as ep___admin_invite_list from './endpoints/admin/invite/list.js';
 import * as ep___admin_promo_create from './endpoints/admin/promo/create.js';
 import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js';
 import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js';
@@ -230,6 +231,10 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
 import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
 import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js';
 import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js';
+import * as ep___invite_create from './endpoints/invite/create.js';
+import * as ep___invite_delete from './endpoints/invite/delete.js';
+import * as ep___invite_list from './endpoints/invite/list.js';
+import * as ep___invite_limit from './endpoints/invite/limit.js';
 import * as ep___meta from './endpoints/meta.js';
 import * as ep___emojis from './endpoints/emojis.js';
 import * as ep___emoji from './endpoints/emoji.js';
@@ -378,7 +383,8 @@ const $admin_federation_updateInstance: Provider = { provide: 'ep:admin/federati
 const $admin_getIndexStats: Provider = { provide: 'ep:admin/get-index-stats', useClass: ep___admin_getIndexStats.default };
 const $admin_getTableStats: Provider = { provide: 'ep:admin/get-table-stats', useClass: ep___admin_getTableStats.default };
 const $admin_getUserIps: Provider = { provide: 'ep:admin/get-user-ips', useClass: ep___admin_getUserIps.default };
-const $invite: Provider = { provide: 'ep:invite', useClass: ep___invite.default };
+const $admin_invite_create: Provider = { provide: 'ep:admin/invite/create', useClass: ep___admin_invite_create.default };
+const $admin_invite_list: Provider = { provide: 'ep:admin/invite/list', useClass: ep___admin_invite_list.default };
 const $admin_promo_create: Provider = { provide: 'ep:admin/promo/create', useClass: ep___admin_promo_create.default };
 const $admin_queue_clear: Provider = { provide: 'ep:admin/queue/clear', useClass: ep___admin_queue_clear.default };
 const $admin_queue_deliverDelayed: Provider = { provide: 'ep:admin/queue/deliver-delayed', useClass: ep___admin_queue_deliverDelayed.default };
@@ -570,6 +576,10 @@ const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep
 const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default };
 const $i_webhooks_update: Provider = { provide: 'ep:i/webhooks/update', useClass: ep___i_webhooks_update.default };
 const $i_webhooks_delete: Provider = { provide: 'ep:i/webhooks/delete', useClass: ep___i_webhooks_delete.default };
+const $invite_create: Provider = { provide: 'ep:invite/create', useClass: ep___invite_create.default };
+const $invite_delete: Provider = { provide: 'ep:invite/delete', useClass: ep___invite_delete.default };
+const $invite_list: Provider = { provide: 'ep:invite/list', useClass: ep___invite_list.default };
+const $invite_limit: Provider = { provide: 'ep:invite/limit', useClass: ep___invite_limit.default };
 const $meta: Provider = { provide: 'ep:meta', useClass: ep___meta.default };
 const $emojis: Provider = { provide: 'ep:emojis', useClass: ep___emojis.default };
 const $emoji: Provider = { provide: 'ep:emoji', useClass: ep___emoji.default };
@@ -722,7 +732,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$admin_getIndexStats,
 		$admin_getTableStats,
 		$admin_getUserIps,
-		$invite,
+		$admin_invite_create,
+		$admin_invite_list,
 		$admin_promo_create,
 		$admin_queue_clear,
 		$admin_queue_deliverDelayed,
@@ -914,6 +925,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$i_webhooks_show,
 		$i_webhooks_update,
 		$i_webhooks_delete,
+		$invite_create,
+		$invite_delete,
+		$invite_list,
+		$invite_limit,
 		$meta,
 		$emojis,
 		$emoji,
@@ -1060,7 +1075,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$admin_getIndexStats,
 		$admin_getTableStats,
 		$admin_getUserIps,
-		$invite,
+		$admin_invite_create,
+		$admin_invite_list,
 		$admin_promo_create,
 		$admin_queue_clear,
 		$admin_queue_deliverDelayed,
@@ -1252,6 +1268,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
 		$i_webhooks_show,
 		$i_webhooks_update,
 		$i_webhooks_delete,
+		$invite_create,
+		$invite_delete,
+		$invite_list,
+		$invite_limit,
 		$meta,
 		$emojis,
 		$emoji,
diff --git a/packages/backend/src/server/api/SignupApiService.ts b/packages/backend/src/server/api/SignupApiService.ts
index 5e18dcbe08..d681bf8e21 100644
--- a/packages/backend/src/server/api/SignupApiService.ts
+++ b/packages/backend/src/server/api/SignupApiService.ts
@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
 import bcrypt from 'bcryptjs';
 import { IsNull } from 'typeorm';
 import { DI } from '@/di-symbols.js';
-import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
+import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, RegistrationTicket } from '@/models/index.js';
 import type { Config } from '@/config.js';
 import { MetaService } from '@/core/MetaService.js';
 import { CaptchaService } from '@/core/CaptchaService.js';
@@ -109,13 +109,15 @@ export class SignupApiService {
 			}
 		}
 
+		let ticket: RegistrationTicket | null = null;
+
 		if (instance.disableRegistration) {
 			if (invitationCode == null || typeof invitationCode !== 'string') {
 				reply.code(400);
 				return;
 			}
 
-			const ticket = await this.registrationTicketsRepository.findOneBy({
+			ticket = await this.registrationTicketsRepository.findOneBy({
 				code: invitationCode,
 			});
 
@@ -124,7 +126,15 @@ export class SignupApiService {
 				return;
 			}
 
-			this.registrationTicketsRepository.delete(ticket.id);
+			if (ticket.expiresAt && ticket.expiresAt < new Date()) {
+				reply.code(400);
+				return;
+			}
+
+			if (ticket.usedAt) {
+				reply.code(400);
+				return;
+			}
 		}
 
 		if (instance.emailRequiredForSignup) {
@@ -148,14 +158,14 @@ export class SignupApiService {
 			const salt = await bcrypt.genSalt(8);
 			const hash = await bcrypt.hash(password, salt);
 
-			await this.userPendingsRepository.insert({
+			const pendingUser = await this.userPendingsRepository.insert({
 				id: this.idService.genId(),
 				createdAt: new Date(),
 				code,
 				email: emailAddress!,
 				username: username,
 				password: hash,
-			});
+			}).then(x => this.userPendingsRepository.findOneByOrFail(x.identifiers[0]));
 
 			const link = `${this.config.url}/signup-complete/${code}`;
 
@@ -163,6 +173,13 @@ export class SignupApiService {
 				`To complete signup, please click this link:<br><a href="${link}">${link}</a>`,
 				`To complete signup, please click this link: ${link}`);
 
+			if (ticket) {
+				await this.registrationTicketsRepository.update(ticket.id, {
+					usedAt: new Date(),
+					pendingUserId: pendingUser.id,
+				});
+			}
+
 			reply.code(204);
 			return;
 		} else {
@@ -176,6 +193,14 @@ export class SignupApiService {
 					includeSecrets: true,
 				});
 
+				if (ticket) {
+					await this.registrationTicketsRepository.update(ticket.id, {
+						usedAt: new Date(),
+						usedBy: account,
+						usedById: account.id,
+					});
+				}
+
 				return {
 					...res,
 					token: secret,
@@ -212,6 +237,15 @@ export class SignupApiService {
 				emailVerifyCode: null,
 			});
 
+			const ticket = await this.registrationTicketsRepository.findOneBy({ pendingUserId: pendingUser.id });
+			if (ticket) {
+				await this.registrationTicketsRepository.update(ticket.id, {
+					usedBy: account,
+					usedById: account.id,
+					pendingUserId: null,
+				});
+			}
+
 			return this.signinService.signin(request, reply, account as LocalUser);
 		} catch (err) {
 			throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString());
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 94206ef870..41c3a29eec 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -38,7 +38,8 @@ import * as ep___admin_federation_updateInstance from './endpoints/admin/federat
 import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js';
 import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js';
 import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js';
-import * as ep___invite from './endpoints/invite.js';
+import * as ep___admin_invite_create from './endpoints/admin/invite/create.js';
+import * as ep___admin_invite_list from './endpoints/admin/invite/list.js';
 import * as ep___admin_promo_create from './endpoints/admin/promo/create.js';
 import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js';
 import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js';
@@ -230,6 +231,10 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
 import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
 import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js';
 import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js';
+import * as ep___invite_create from './endpoints/invite/create.js';
+import * as ep___invite_delete from './endpoints/invite/delete.js';
+import * as ep___invite_list from './endpoints/invite/list.js';
+import * as ep___invite_limit from './endpoints/invite/limit.js';
 import * as ep___meta from './endpoints/meta.js';
 import * as ep___emojis from './endpoints/emojis.js';
 import * as ep___emoji from './endpoints/emoji.js';
@@ -376,7 +381,8 @@ const eps = [
 	['admin/get-index-stats', ep___admin_getIndexStats],
 	['admin/get-table-stats', ep___admin_getTableStats],
 	['admin/get-user-ips', ep___admin_getUserIps],
-	['invite', ep___invite],
+	['admin/invite/create', ep___admin_invite_create],
+	['admin/invite/list', ep___admin_invite_list],
 	['admin/promo/create', ep___admin_promo_create],
 	['admin/queue/clear', ep___admin_queue_clear],
 	['admin/queue/deliver-delayed', ep___admin_queue_deliverDelayed],
@@ -568,6 +574,10 @@ const eps = [
 	['i/webhooks/show', ep___i_webhooks_show],
 	['i/webhooks/update', ep___i_webhooks_update],
 	['i/webhooks/delete', ep___i_webhooks_delete],
+	['invite/create', ep___invite_create],
+	['invite/delete', ep___invite_delete],
+	['invite/list', ep___invite_list],
+	['invite/limit', ep___invite_limit],
 	['meta', ep___meta],
 	['emojis', ep___emojis],
 	['emoji', ep___emoji],
diff --git a/packages/backend/src/server/api/endpoints/admin/invite/create.ts b/packages/backend/src/server/api/endpoints/admin/invite/create.ts
new file mode 100644
index 0000000000..664b4d819f
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/invite/create.ts
@@ -0,0 +1,80 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { RegistrationTicketsRepository } from '@/models/index.js';
+import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
+import { IdService } from '@/core/IdService.js';
+import { DI } from '@/di-symbols.js';
+import { generateInviteCode } from '@/misc/generate-invite-code.js';
+import { ApiError } from '../../../error.js';
+
+export const meta = {
+	tags: ['admin'],
+
+	requireCredential: true,
+	requireModerator: true,
+
+	errors: {
+		invalidDateTime: {
+			message: 'Invalid date-time format',
+			code: 'INVALID_DATE_TIME',
+			id: 'f1380b15-3760-4c6c-a1db-5c3aaf1cbd49',
+		},
+	},
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			optional: false, nullable: false,
+			properties: {
+				code: {
+					type: 'string',
+					optional: false, nullable: false,
+					example: 'GR6S02ERUA5VR',
+				},
+			},
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		count: { type: 'integer', minimum: 1, maximum: 100, default: 1 },
+		expiresAt: { type: 'string', nullable: true },
+	},
+	required: [],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.registrationTicketsRepository)
+		private registrationTicketsRepository: RegistrationTicketsRepository,
+
+		private inviteCodeEntityService: InviteCodeEntityService,
+		private idService: IdService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			if (ps.expiresAt && isNaN(Date.parse(ps.expiresAt))) {
+				throw new ApiError(meta.errors.invalidDateTime);
+			}
+
+			const ticketsPromises = [];
+
+			for (let i = 0; i < ps.count; i++) {
+				ticketsPromises.push(this.registrationTicketsRepository.insert({
+					id: this.idService.genId(),
+					createdAt: new Date(),
+					expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null,
+					code: generateInviteCode(),
+				}).then(x => this.registrationTicketsRepository.findOneByOrFail(x.identifiers[0])));
+			}
+
+			const tickets = await Promise.all(ticketsPromises);
+			return await this.inviteCodeEntityService.packMany(tickets, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/admin/invite/list.ts b/packages/backend/src/server/api/endpoints/admin/invite/list.ts
new file mode 100644
index 0000000000..5d7a7f632c
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/admin/invite/list.ts
@@ -0,0 +1,70 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { RegistrationTicketsRepository } from '@/models/index.js';
+import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
+import { DI } from '@/di-symbols.js';
+
+export const meta = {
+	tags: ['admin'],
+
+	requireCredential: true,
+	requireModerator: true,
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			optional: false, nullable: false,
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+		offset: { type: 'integer', default: 0 },
+		type: { type: 'string', enum: ['unused', 'used', 'expired', 'all'], default: 'all' },
+		sort: { type: 'string', enum: ['+createdAt', '-createdAt', '+usedAt', '-usedAt'] },
+	},
+	required: [],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.registrationTicketsRepository)
+		private registrationTicketsRepository: RegistrationTicketsRepository,
+
+		private inviteCodeEntityService: InviteCodeEntityService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const query = this.registrationTicketsRepository.createQueryBuilder('ticket')
+				.leftJoinAndSelect('ticket.createdBy', 'createdBy')
+				.leftJoinAndSelect('ticket.usedBy', 'usedBy');
+
+			switch (ps.type) {
+				case 'unused': query.andWhere('ticket.usedBy IS NULL'); break;
+				case 'used': query.andWhere('ticket.usedBy IS NOT NULL'); break;
+				case 'expired': query.andWhere('ticket.expiresAt < :now', { now: new Date() }); break;
+			}
+
+			switch (ps.sort) {
+				case '+createdAt': query.orderBy('ticket.createdAt', 'DESC'); break;
+				case '-createdAt': query.orderBy('ticket.createdAt', 'ASC'); break;
+				case '+usedAt': query.orderBy('ticket.usedAt', 'DESC', 'NULLS LAST'); break;
+				case '-usedAt': query.orderBy('ticket.usedAt', 'ASC', 'NULLS FIRST'); break;
+				default: query.orderBy('ticket.id', 'DESC'); break;
+			}
+
+			query.limit(ps.limit);
+			query.skip(ps.offset);
+
+			const tickets = await query.getMany();
+
+			return await this.inviteCodeEntityService.packMany(tickets, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/invite.ts b/packages/backend/src/server/api/endpoints/invite.ts
deleted file mode 100644
index 276adcb07f..0000000000
--- a/packages/backend/src/server/api/endpoints/invite.ts
+++ /dev/null
@@ -1,60 +0,0 @@
-import { Inject, Injectable } from '@nestjs/common';
-import { Endpoint } from '@/server/api/endpoint-base.js';
-import type { RegistrationTicketsRepository } from '@/models/index.js';
-import { IdService } from '@/core/IdService.js';
-import { DI } from '@/di-symbols.js';
-import { secureRndstr } from '@/misc/secure-rndstr.js';
-
-export const meta = {
-	tags: ['meta'],
-
-	requireCredential: true,
-	requireRolePolicy: 'canInvite',
-
-	res: {
-		type: 'object',
-		optional: false, nullable: false,
-		properties: {
-			code: {
-				type: 'string',
-				optional: false, nullable: false,
-				example: '2ERUA5VR',
-				maxLength: 8,
-				minLength: 8,
-			},
-		},
-	},
-} as const;
-
-export const paramDef = {
-	type: 'object',
-	properties: {},
-	required: [],
-} as const;
-
-// eslint-disable-next-line import/no-default-export
-@Injectable()
-export default class extends Endpoint<typeof meta, typeof paramDef> {
-	constructor(
-		@Inject(DI.registrationTicketsRepository)
-		private registrationTicketsRepository: RegistrationTicketsRepository,
-
-		private idService: IdService,
-	) {
-		super(meta, paramDef, async (ps, me) => {
-			const code = secureRndstr(8, {
-				chars: '23456789ABCDEFGHJKLMNPQRSTUVWXYZ', // [0-9A-Z] w/o [01IO] (32 patterns)
-			});
-
-			await this.registrationTicketsRepository.insert({
-				id: this.idService.genId(),
-				createdAt: new Date(),
-				code,
-			});
-
-			return {
-				code,
-			};
-		});
-	}
-}
diff --git a/packages/backend/src/server/api/endpoints/invite/create.ts b/packages/backend/src/server/api/endpoints/invite/create.ts
new file mode 100644
index 0000000000..a64184be10
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/invite/create.ts
@@ -0,0 +1,82 @@
+import { MoreThan } from 'typeorm';
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { RegistrationTicketsRepository } from '@/models/index.js';
+import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
+import { IdService } from '@/core/IdService.js';
+import { RoleService } from '@/core/RoleService.js';
+import { DI } from '@/di-symbols.js';
+import { generateInviteCode } from '@/misc/generate-invite-code.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+	tags: ['meta'],
+
+	requireCredential: true,
+	requireRolePolicy: 'canInvite',
+
+	errors: {
+		exceededCreateLimit: {
+			message: 'You have exceeded the limit for creating an invitation code.',
+			code: 'EXCEEDED_LIMIT_OF_CREATE_INVITE_CODE',
+			id: '8b165dd3-6f37-4557-8db1-73175d63c641',
+		},
+	},
+
+	res: {
+		type: 'object',
+		optional: false, nullable: false,
+		properties: {
+			code: {
+				type: 'string',
+				optional: false, nullable: false,
+				example: 'GR6S02ERUA5VR',
+			},
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {},
+	required: [],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.registrationTicketsRepository)
+		private registrationTicketsRepository: RegistrationTicketsRepository,
+
+		private inviteCodeEntityService: InviteCodeEntityService,
+		private idService: IdService,
+		private roleService: RoleService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const policies = await this.roleService.getUserPolicies(me.id);
+
+			if (policies.inviteLimit) {
+				const count = await this.registrationTicketsRepository.countBy({
+					createdAt: MoreThan(new Date(Date.now() - (policies.inviteLimitCycle * 1000 * 60))),
+					createdById: me.id,
+				});
+
+				if (count >= policies.inviteLimit) {
+					throw new ApiError(meta.errors.exceededCreateLimit);
+				}
+			}
+
+			const ticket = await this.registrationTicketsRepository.insert({
+				id: this.idService.genId(),
+				createdAt: new Date(),
+				createdBy: me,
+				createdById: me.id,
+				expiresAt: policies.inviteExpirationTime ? new Date(Date.now() + (policies.inviteExpirationTime * 1000 * 60)) : null,
+				code: generateInviteCode(),
+			}).then(x => this.registrationTicketsRepository.findOneByOrFail(x.identifiers[0]));
+
+			return await this.inviteCodeEntityService.pack(ticket, me);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/invite/delete.ts b/packages/backend/src/server/api/endpoints/invite/delete.ts
new file mode 100644
index 0000000000..afca44954d
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/invite/delete.ts
@@ -0,0 +1,71 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { RegistrationTicketsRepository } from '@/models/index.js';
+import { RoleService } from '@/core/RoleService.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+	tags: ['meta'],
+
+	requireCredential: true,
+	requireRolePolicy: 'canInvite',
+
+	errors: {
+		noSuchCode: {
+			message: 'No such invite code.',
+			code: 'NO_SUCH_INVITE_CODE',
+			id: 'cd4f9ae4-7854-4e3e-8df9-c296f051e634',
+		},
+
+		cantDelete: {
+			message: 'You can\'t delete this invite code.',
+			code: 'CAN_NOT_DELETE_INVITE_CODE',
+			id: 'ff17af39-000c-4d4e-abdf-848fa30fc1ce',
+		},
+
+		accessDenied: {
+			message: 'Access denied.',
+			code: 'ACCESS_DENIED',
+			id: '5eb8d909-2540-4970-90b8-dd6f86088121',
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		inviteId: { type: 'string', format: 'misskey:id' },
+	},
+	required: ['inviteId'],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.registrationTicketsRepository)
+		private registrationTicketsRepository: RegistrationTicketsRepository,
+
+		private roleService: RoleService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const ticket = await this.registrationTicketsRepository.findOneBy({ id: ps.inviteId });
+			const isModerator = await this.roleService.isModerator(me);
+
+			if (ticket == null) {
+				throw new ApiError(meta.errors.noSuchCode);
+			}
+
+			if (ticket.createdById !== me.id && !isModerator) {
+				throw new ApiError(meta.errors.accessDenied);
+			}
+
+			if (ticket.usedAt && !isModerator) {
+				throw new ApiError(meta.errors.cantDelete);
+			}
+
+			await this.registrationTicketsRepository.delete(ticket.id);
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/invite/limit.ts b/packages/backend/src/server/api/endpoints/invite/limit.ts
new file mode 100644
index 0000000000..9a213b7b25
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/invite/limit.ts
@@ -0,0 +1,54 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { MoreThan } from 'typeorm';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { RegistrationTicketsRepository } from '@/models/index.js';
+import { RoleService } from '@/core/RoleService.js';
+import { DI } from '@/di-symbols.js';
+
+export const meta = {
+	tags: ['meta'],
+
+	requireCredential: true,
+	requireRolePolicy: 'canInvite',
+
+	res: {
+		type: 'object',
+		optional: false, nullable: false,
+		properties: {
+			remaining: {
+				type: 'integer',
+				optional: false, nullable: true,
+			},
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {},
+	required: [],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.registrationTicketsRepository)
+		private registrationTicketsRepository: RegistrationTicketsRepository,
+
+		private roleService: RoleService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const policies = await this.roleService.getUserPolicies(me.id);
+
+			const count = policies.inviteLimit ? await this.registrationTicketsRepository.countBy({
+				createdAt: MoreThan(new Date(Date.now() - (policies.inviteExpirationTime * 60 * 1000))),
+				createdById: me.id,
+			}) : null;
+
+			return {
+				remaining: count !== null ? Math.max(0, policies.inviteLimit - count) : null,
+			};
+		});
+	}
+}
diff --git a/packages/backend/src/server/api/endpoints/invite/list.ts b/packages/backend/src/server/api/endpoints/invite/list.ts
new file mode 100644
index 0000000000..e047790261
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/invite/list.ts
@@ -0,0 +1,58 @@
+import { Inject, Injectable } from '@nestjs/common';
+import { Endpoint } from '@/server/api/endpoint-base.js';
+import type { RegistrationTicketsRepository } from '@/models/index.js';
+import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
+import { QueryService } from '@/core/QueryService.js';
+import { DI } from '@/di-symbols.js';
+import { ApiError } from '../../error.js';
+
+export const meta = {
+	tags: ['meta'],
+
+	requireCredential: true,
+	requireRolePolicy: 'canInvite',
+
+	res: {
+		type: 'array',
+		optional: false, nullable: false,
+		items: {
+			type: 'object',
+			optional: false, nullable: false,
+		},
+	},
+} as const;
+
+export const paramDef = {
+	type: 'object',
+	properties: {
+		limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
+		sinceId: { type: 'string', format: 'misskey:id' },
+		untilId: { type: 'string', format: 'misskey:id' },
+	},
+	required: [],
+} as const;
+
+// eslint-disable-next-line import/no-default-export
+@Injectable()
+export default class extends Endpoint<typeof meta, typeof paramDef> {
+	constructor(
+		@Inject(DI.registrationTicketsRepository)
+		private registrationTicketsRepository: RegistrationTicketsRepository,
+
+		private inviteCodeEntityService: InviteCodeEntityService,
+		private queryService: QueryService,
+	) {
+		super(meta, paramDef, async (ps, me) => {
+			const query = this.queryService.makePaginationQuery(this.registrationTicketsRepository.createQueryBuilder('ticket'), ps.sinceId, ps.untilId)
+				.andWhere('ticket.createdById = :meId', { meId: me.id })
+				.leftJoinAndSelect('ticket.createdBy', 'createdBy')
+				.leftJoinAndSelect('ticket.usedBy', 'usedBy');
+
+			const tickets = await query
+				.limit(ps.limit)
+				.getMany();
+
+			return await this.inviteCodeEntityService.packMany(tickets, me);
+		});
+	}
+}
diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts
index 5fd21cdf0a..a4289cff7d 100644
--- a/packages/frontend/.storybook/fakes.ts
+++ b/packages/frontend/.storybook/fakes.ts
@@ -115,3 +115,27 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
 		url: null,
 	};
 }
+
+export function inviteCode(isUsed = false, hasExpiration = false, isExpired = false, isCreatedBySystem = false) {
+	const date = new Date();
+	const createdAt = new Date();
+	createdAt.setDate(date.getDate() - 1)
+	const expiresAt = new Date();
+
+	if (isExpired) {
+		expiresAt.setHours(date.getHours() - 1)
+	} else {
+		expiresAt.setHours(date.getHours() + 1)
+	}
+
+	return {
+		id: "9gyqzizw77",
+		code: "SLF3JKF7UV2H9",
+		expiresAt: hasExpiration ? expiresAt.toISOString() : null,
+		createdAt: createdAt.toISOString(),
+		createdBy: isCreatedBySystem ? null : userDetailed('8i3rvznx32'),
+		usedBy: isUsed ? userDetailed('3i3r2znx1v') : null,
+		usedAt: isUsed ? date.toISOString() : null,
+		used: isUsed,
+	}
+}
diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx
index b3d7bd8f5e..d47d8672c7 100644
--- a/packages/frontend/.storybook/generate.tsx
+++ b/packages/frontend/.storybook/generate.tsx
@@ -403,6 +403,7 @@ function toStories(component: string): Promise<string> {
 		glob('src/components/MkSignupServerRules.vue'),
 		glob('src/components/MkUserSetupDialog.vue'),
 		glob('src/components/MkUserSetupDialog.*.vue'),
+		glob('src/components/MkInviteCode.vue'),
 		glob('src/pages/user/home.vue'),
 	]);
 	const components = globs.flat();
diff --git a/packages/frontend/src/components/MkInviteCode.stories.impl.ts b/packages/frontend/src/components/MkInviteCode.stories.impl.ts
new file mode 100644
index 0000000000..def0a96e6a
--- /dev/null
+++ b/packages/frontend/src/components/MkInviteCode.stories.impl.ts
@@ -0,0 +1,60 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import { rest } from 'msw';
+import { userDetailed, inviteCode } from '../../.storybook/fakes';
+import { commonHandlers } from '../../.storybook/mocks';
+import MkInviteCode from './MkInviteCode.vue';
+
+export const Default = {
+	render(args) {
+		return {
+			components: {
+				MkInviteCode,
+			},
+			setup() {
+				return {
+					args,
+				};
+			},
+			computed: {
+				props() {
+					return {
+						...this.args,
+					};
+				},
+			},
+			template: '<MkInviteCode v-bind="props" />',
+		};
+	},
+	args: {
+		invite: inviteCode() as any,
+	},
+	parameters: {
+		layout: 'centered',
+		msw: {
+			handlers: [
+				...commonHandlers,
+				rest.post('/api/users/show', (req, res, ctx) => {
+					return res(ctx.json(userDetailed(req.params.userId as string)));
+				}),
+			],
+		},
+	},
+	decorators: [() => ({
+		template: '<div style="width:100cqmin"><story/></div>',
+	})],
+} satisfies StoryObj<typeof MkInviteCode>;
+
+export const Used = {
+	...Default,
+	args: {
+		invite: inviteCode(true) as any
+	},
+} satisfies StoryObj<typeof MkInviteCode>;
+
+export const Expired = {
+	...Default,
+	args: {
+		invite: inviteCode(false, true, true) as any
+	},
+} satisfies StoryObj<typeof MkInviteCode>;
diff --git a/packages/frontend/src/components/MkInviteCode.vue b/packages/frontend/src/components/MkInviteCode.vue
new file mode 100644
index 0000000000..fdde79b178
--- /dev/null
+++ b/packages/frontend/src/components/MkInviteCode.vue
@@ -0,0 +1,123 @@
+<template>
+<MkFolder>
+	<template #label>{{ invite.code }}</template>
+	<template #suffix>
+		<span v-if="invite.used">{{ i18n.ts.used }}</span>
+		<span v-else-if="isExpired" style="color: var(--error)">{{ i18n.ts.expired }}</span>
+		<span v-else style="color: var(--success)">{{ i18n.ts.unused }}</span>
+	</template>
+
+	<div class="_gaps_s" :class="$style.root">
+		<div :class="$style.items">
+			<div>
+				<div :class="$style.label">{{ i18n.ts.invitationCode }}</div>
+				<div>{{ invite.code }}</div>
+			</div>
+			<div v-if="moderator">
+				<div :class="$style.label">{{ i18n.ts.inviteCodeCreator }}</div>
+				<div v-if="invite.createdBy" :class="$style.user">
+					<MkAvatar :user="invite.createdBy" :class="$style.avatar" link preview/>
+					<MkUserName :user="invite.createdBy" :nowrap="false"/>
+					<div v-if="moderator">({{ invite.createdBy.id }})</div>
+				</div>
+				<div v-else>system</div>
+			</div>
+			<div v-if="invite.used">
+				<div :class="$style.label">{{ i18n.ts.registeredUserUsingInviteCode }}</div>
+				<div v-if="invite.usedBy" :class="$style.user">
+					<MkAvatar :user="invite.usedBy" :class="$style.avatar" link preview/>
+					<MkUserName :user="invite.usedBy" :nowrap="false"/>
+					<div v-if="moderator">({{ invite.usedBy.id }})</div>
+				</div>
+				<div v-else>{{ i18n.ts.unknown }} ({{ i18n.ts.waitingForMailAuth }})</div>
+			</div>
+			<div v-if="invite.expiresAt && !invite.used">
+				<div :class="$style.label">{{ i18n.ts.expirationDate }}</div>
+				<div><MkTime :time="invite.expiresAt" mode="absolute"/></div>
+			</div>
+			<div v-if="invite.usedAt">
+				<div :class="$style.label">{{ i18n.ts.inviteCodeUsedAt }}</div>
+				<div><MkTime :time="invite.usedAt" mode="absolute"/></div>
+			</div>
+			<div v-if="moderator">
+				<div :class="$style.label">{{ i18n.ts.createdAt }}</div>
+				<div><MkTime :time="invite.createdAt" mode="absolute"/></div>
+			</div>
+		</div>
+		<div :class="$style.buttons">
+			<MkButton v-if="!invite.used && !isExpired" primary rounded @click="copyInviteCode()">{{ i18n.ts.copy }}</MkButton>
+			<MkButton v-if="!invite.used || moderator" danger rounded @click="deleteCode()">{{ i18n.ts.delete }}</MkButton>
+		</div>
+	</div>
+</MkFolder>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as misskey from 'misskey-js';
+import MkFolder from '@/components/MkFolder.vue';
+import MkButton from '@/components/MkButton.vue';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { i18n } from '@/i18n';
+import * as os from '@/os';
+
+const props = defineProps<{
+	invite: misskey.entities.Invite;
+	moderator?: boolean;
+}>();
+
+const emits = defineEmits<{
+	(event: 'deleted', value: string): void;
+}>();
+
+const isExpired = computed(() => {
+	return props.invite.expiresAt && new Date(props.invite.expiresAt) < new Date();
+});
+
+function deleteCode() {
+	os.apiWithDialog('invite/delete', {
+		inviteId: props.invite.id,
+	});
+	emits('deleted', props.invite.id);
+}
+
+function copyInviteCode() {
+	copyToClipboard(props.invite.code);
+	os.success();
+}
+</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;
+}
+
+.user {
+	display: flex;
+	align-items: center;
+	gap: 8px;
+}
+
+.avatar {
+	--height: 24px;
+	width: var(--height);
+	height: var(--height);
+}
+
+.buttons {
+	display: flex;
+	gap: 8px;
+}
+</style>
diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts
index ad7fa372e9..1d883c038e 100644
--- a/packages/frontend/src/const.ts
+++ b/packages/frontend/src/const.ts
@@ -57,6 +57,9 @@ export const ROLE_POLICIES = [
 	'ltlAvailable',
 	'canPublicNote',
 	'canInvite',
+	'inviteLimit',
+	'inviteLimitCycle',
+	'inviteExpirationTime',
 	'canManageCustomEmojis',
 	'canSearchNotes',
 	'canHideAds',
diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue
index 226eb8d026..e91f65b5d5 100644
--- a/packages/frontend/src/pages/admin/index.vue
+++ b/packages/frontend/src/pages/admin/index.vue
@@ -80,7 +80,7 @@ const menuDef = $computed(() => [{
 	}, ...(instance.disableRegistration ? [{
 		type: 'button',
 		icon: 'ti ti-user-plus',
-		text: i18n.ts.invite,
+		text: i18n.ts.createInviteCode,
 		action: invite,
 	}] : [])],
 }, {
@@ -95,6 +95,11 @@ const menuDef = $computed(() => [{
 		text: i18n.ts.users,
 		to: '/admin/users',
 		active: currentPage?.route.name === 'users',
+	}, {
+		icon: 'ti ti-user-plus',
+		text: i18n.ts.invite,
+		to: '/admin/invites',
+		active: currentPage?.route.name === 'invites',
 	}, {
 		icon: 'ti ti-badges',
 		text: i18n.ts.roles,
@@ -240,10 +245,10 @@ provideMetadataReceiver((info) => {
 });
 
 const invite = () => {
-	os.api('invite').then(x => {
+	os.api('admin/invite/create').then(x => {
 		os.alert({
 			type: 'info',
-			text: x.code,
+			text: x?.[0].code,
 		});
 	}).catch(err => {
 		os.alert({
diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue
new file mode 100644
index 0000000000..70a9c93713
--- /dev/null
+++ b/packages/frontend/src/pages/admin/invites.vue
@@ -0,0 +1,126 @@
+<template>
+<MkStickyContainer>
+	<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+	<MkSpacer :contentMax="800">
+		<div class="_gaps_m">
+			<MkFolder :expanded="false">
+				<template #icon><i class="ti ti-plus"></i></template>
+				<template #label>{{ i18n.ts.createInviteCode }}</template>
+
+				<div class="_gaps_m">
+					<MkSwitch v-model="noExpirationDate">
+						<template #label>{{ i18n.ts.noExpirationDate }}</template>
+					</MkSwitch>
+					<MkInput v-if="!noExpirationDate" v-model="expiresAt" type="datetime-local">
+						<template #label>{{ i18n.ts.expirationDate }}</template>
+					</MkInput>
+					<MkInput v-model="createCount" type="number">
+						<template #label>{{ i18n.ts.createCount }}</template>
+					</MkInput>
+					<MkButton primary rounded @click="createWithOptions">{{ i18n.ts.create }}</MkButton>
+				</div>
+			</MkFolder>
+
+			<div :class="$style.inputs">
+				<MkSelect v-model="type" :class="$style.input">
+					<template #label>{{ i18n.ts.state }}</template>
+					<option value="all">{{ i18n.ts.all }}</option>
+					<option value="unused">{{ i18n.ts.unused }}</option>
+					<option value="used">{{ i18n.ts.used }}</option>
+					<option value="expired">{{ i18n.ts.expired }}</option>
+				</MkSelect>
+				<MkSelect v-model="sort" :class="$style.input">
+					<template #label>{{ i18n.ts.sort }}</template>
+					<option value="+createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.ascendingOrder }})</option>
+					<option value="-createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.descendingOrder }})</option>
+					<option value="+usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.ascendingOrder }})</option>
+					<option value="-usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.descendingOrder }})</option>
+				</MkSelect>
+			</div>
+			<MkPagination ref="pagingComponent" :pagination="pagination">
+				<template #default="{ items }">
+					<div class="_gaps_s">
+						<MkInviteCode v-for="item in items" :key="item.id" :invite="(item as any)" :onDeleted="deleted" moderator/>
+					</div>
+				</template>
+			</MkPagination>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, shallowRef } from 'vue';
+import XHeader from './_header_.vue';
+import { i18n } from '@/i18n';
+import * as os from '@/os';
+import MkButton from '@/components/MkButton.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkSelect from '@/components/MkSelect.vue';
+import MkInput from '@/components/MkInput.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import MkPagination, { Paging } from '@/components/MkPagination.vue';
+import MkInviteCode from '@/components/MkInviteCode.vue';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
+
+let type = ref('all');
+let sort = ref('+createdAt');
+
+const pagination: Paging = {
+	endpoint: 'admin/invite/list' as const,
+	limit: 10,
+	params: computed(() => ({
+		type: type.value,
+		sort: sort.value,
+	})),
+	offsetMode: true,
+};
+
+const expiresAt = ref('');
+const noExpirationDate = ref(true);
+const createCount = ref(1);
+
+async function createWithOptions() {
+	const options = {
+		expiresAt: noExpirationDate.value ? null : expiresAt.value,
+		count: createCount.value,
+	};
+
+	const tickets = await os.api('admin/invite/create', options);
+	os.alert({
+		type: 'success',
+		title: i18n.ts.inviteCodeCreated,
+		text: tickets?.map(x => x.code).join('\n'),
+	});
+
+	tickets?.forEach(ticket => pagingComponent.value?.prepend(ticket));
+}
+
+function deleted(id: string) {
+	if (pagingComponent.value) {
+		pagingComponent.value.items.delete(id);
+	}
+}
+
+const headerActions = $computed(() => []);
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+	title: i18n.ts.invite,
+	icon: 'ti ti-user-plus',
+});
+</script>
+
+<style lang="scss" module>
+.inputs {
+	display: flex;
+	gap: 8px;
+	flex-wrap: wrap;
+}
+
+.input {
+	flex: 1;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 02a2d4366a..7fe5624fb5 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -171,6 +171,65 @@
 				</div>
 			</MkFolder>
 
+			<MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimit, 'inviteLimit'])">
+				<template #label>{{ i18n.ts._role._options.inviteLimit }}</template>
+				<template #suffix>
+					<span v-if="role.policies.inviteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+					<span v-else>{{ role.policies.inviteLimit.value }}</span>
+					<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.inviteLimit)"></i></span>
+				</template>
+				<div class="_gaps">
+					<MkSwitch v-model="role.policies.inviteLimit.useDefault" :readonly="readonly">
+						<template #label>{{ i18n.ts._role.useBaseValue }}</template>
+					</MkSwitch>
+					<MkInput v-model="role.policies.inviteLimit.value" :disabled="role.policies.inviteLimit.useDefault" type="number" :readonly="readonly">
+					</MkInput>
+					<MkRange v-model="role.policies.inviteLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+						<template #label>{{ i18n.ts._role.priority }}</template>
+					</MkRange>
+				</div>
+			</MkFolder>
+
+			<MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimitCycle, 'inviteLimitCycle'])">
+				<template #label>{{ i18n.ts._role._options.inviteLimitCycle }}</template>
+				<template #suffix>
+					<span v-if="role.policies.inviteLimitCycle.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+					<span v-else>{{ role.policies.inviteLimitCycle.value + i18n.ts._time.minute }}</span>
+					<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.inviteLimitCycle)"></i></span>
+				</template>
+				<div class="_gaps">
+					<MkSwitch v-model="role.policies.inviteLimitCycle.useDefault" :readonly="readonly">
+						<template #label>{{ i18n.ts._role.useBaseValue }}</template>
+					</MkSwitch>
+					<MkInput v-model="role.policies.inviteLimitCycle.value" :disabled="role.policies.inviteLimitCycle.useDefault" type="number" :readonly="readonly">
+						<template #suffix>{{ i18n.ts._time.minute }}</template>
+					</MkInput>
+					<MkRange v-model="role.policies.inviteLimitCycle.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+						<template #label>{{ i18n.ts._role.priority }}</template>
+					</MkRange>
+				</div>
+			</MkFolder>
+
+			<MkFolder v-if="matchQuery([i18n.ts._role._options.inviteExpirationTime, 'inviteExpirationTime'])">
+				<template #label>{{ i18n.ts._role._options.inviteExpirationTime }}</template>
+				<template #suffix>
+					<span v-if="role.policies.inviteExpirationTime.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+					<span v-else>{{ role.policies.inviteExpirationTime.value + i18n.ts._time.minute }}</span>
+					<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.inviteExpirationTime)"></i></span>
+				</template>
+				<div class="_gaps">
+					<MkSwitch v-model="role.policies.inviteExpirationTime.useDefault" :readonly="readonly">
+						<template #label>{{ i18n.ts._role.useBaseValue }}</template>
+					</MkSwitch>
+					<MkInput v-model="role.policies.inviteExpirationTime.value" :disabled="role.policies.inviteExpirationTime.useDefault" type="number" :readonly="readonly">
+						<template #suffix>{{ i18n.ts._time.minute }}</template>
+					</MkInput>
+					<MkRange v-model="role.policies.inviteExpirationTime.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+						<template #label>{{ i18n.ts._role.priority }}</template>
+					</MkRange>
+				</div>
+			</MkFolder>
+
 			<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])">
 				<template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template>
 				<template #suffix>
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index 6634d9cba9..cdb6e90505 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -51,6 +51,29 @@
 							</MkSwitch>
 						</MkFolder>
 
+						<MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimit, 'inviteLimit'])">
+							<template #label>{{ i18n.ts._role._options.inviteLimit }}</template>
+							<template #suffix>{{ policies.inviteLimit }}</template>
+							<MkInput v-model="policies.inviteLimit" type="number">
+							</MkInput>
+						</MkFolder>
+						
+						<MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimitCycle, 'inviteLimitCycle'])">
+							<template #label>{{ i18n.ts._role._options.inviteLimitCycle }}</template>
+							<template #suffix>{{ policies.inviteLimitCycle + i18n.ts._time.minute }}</template>
+							<MkInput v-model="policies.inviteLimitCycle" type="number">
+								<template #suffix>{{ i18n.ts._time.minute }}</template>
+							</MkInput>
+						</MkFolder>
+
+						<MkFolder v-if="matchQuery([i18n.ts._role._options.inviteExpirationTime, 'inviteExpirationTime'])">
+							<template #label>{{ i18n.ts._role._options.inviteExpirationTime }}</template>
+							<template #suffix>{{ policies.inviteExpirationTime + i18n.ts._time.minute }}</template>
+							<MkInput v-model="policies.inviteExpirationTime" type="number">
+								<template #suffix>{{ i18n.ts._time.minute }}</template>
+							</MkInput>
+						</MkFolder>
+
 						<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])">
 							<template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template>
 							<template #suffix>{{ policies.canManageCustomEmojis ? i18n.ts.yes : i18n.ts.no }}</template>
diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue
new file mode 100644
index 0000000000..c893ad51e8
--- /dev/null
+++ b/packages/frontend/src/pages/invite.vue
@@ -0,0 +1,114 @@
+<template>
+<MkStickyContainer>
+	<template #header>
+		<MkPageHeader/>
+	</template>
+	<MKSpacer v-if="!instance.disableRegistration || !($i && ($i.isAdmin || $i.policies.canInvite))" :contentMax="1200">
+		<div :class="$style.root">
+			<img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/>
+			<div :class="$style.text">
+				<i class="ti ti-alert-triangle"></i>
+				{{ i18n.ts.nothing }}
+			</div>
+		</div>
+	</MKSpacer>
+	<MkSpacer v-else :contentMax="800">
+		<div class="_gaps_m" style="text-align: center;">
+			<div v-if="resetCycle && inviteLimit">{{ i18n.t('inviteLimitResetCycle', { time: resetCycle, limit: inviteLimit }) }}</div>
+			<MkButton inline primary rounded :disabled="currentInviteLimit !== null && currentInviteLimit <= 0" @click="create"><i class="ti ti-user-plus"></i> {{ i18n.ts.createInviteCode }}</MkButton>
+			<div v-if="currentInviteLimit !== null">{{ i18n.t('createLimitRemaining', { limit: currentInviteLimit }) }}</div>
+
+			<MkPagination ref="pagingComponent" :pagination="pagination">
+				<template #default="{ items }">
+					<div class="_gaps_s">
+						<MkInviteCode v-for="item in (items as Invite[])" :key="item.id" :invite="item" :onDeleted="deleted"/>
+					</div>
+				</template>
+			</MkPagination>
+		</div>
+	</MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, shallowRef } from 'vue';
+import type { Invite } from 'misskey-js/built/entities';
+import { i18n } from '@/i18n';
+import * as os from '@/os';
+import MkButton from '@/components/MkButton.vue';
+import MkPagination, { Paging } from '@/components/MkPagination.vue';
+import MkInviteCode from '@/components/MkInviteCode.vue';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { serverErrorImageUrl, instance } from '@/instance';
+import { $i } from '@/account';
+
+const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
+const currentInviteLimit = ref<null | number>(null);
+const inviteLimit = (($i != null && $i.policies.inviteLimit) || (($i == null && instance.policies.inviteLimit))) as number;
+const inviteLimitCycle = (($i != null && $i.policies.inviteLimitCycle) || ($i == null && instance.policies.inviteLimitCycle)) as number;
+
+const pagination: Paging = {
+	endpoint: 'invite/list' as const,
+	limit: 10,
+};
+
+const resetCycle = computed<null | string>(() => {
+	if (!inviteLimitCycle) return null;
+
+	const minutes = inviteLimitCycle;
+	if (minutes < 60) return minutes + i18n.ts._time.minute;
+	const hours = Math.floor(minutes / 60);
+	if (hours < 24) return hours + i18n.ts._time.hour;
+	return Math.floor(hours / 24) + i18n.ts._time.day;
+});
+
+async function create() {
+	const ticket = await os.api('invite/create');
+	os.alert({
+		type: 'success',
+		title: i18n.ts.inviteCodeCreated,
+		text: ticket.code,
+	});
+
+	pagingComponent.value?.prepend(ticket);
+	update();
+}
+
+function deleted(id: string) {
+	if (pagingComponent.value) {
+		pagingComponent.value.items.delete(id);
+	}
+	update();
+}
+
+async function update() {
+	currentInviteLimit.value = (await os.api('invite/limit')).remaining;
+}
+
+update();
+
+definePageMetadata({
+	title: i18n.ts.invite,
+	icon: 'ti ti-user-plus',
+});
+</script>
+
+<style lang="scss" module>
+.root {
+	padding: 32px;
+	text-align: center;
+	align-items: center;
+}
+
+.text {
+	margin: 0 0 8px 0;
+}
+
+.img {
+	vertical-align: bottom;
+	width: 128px;
+	height: 128px;
+	margin-bottom: 16px;
+	border-radius: 16px;
+}
+</style>
diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts
index 873b372e0a..a4276ff4c0 100644
--- a/packages/frontend/src/router.ts
+++ b/packages/frontend/src/router.ts
@@ -201,6 +201,10 @@ export const routes = [{
 }, {
 	path: '/about-misskey',
 	component: page(() => import('./pages/about-misskey.vue')),
+}, {
+	path: '/invite',
+	name: 'invite',
+	component: page(() => import('./pages/invite.vue')),
 }, {
 	path: '/ads',
 	component: page(() => import('./pages/ads.vue')),
@@ -428,6 +432,10 @@ export const routes = [{
 		path: '/server-rules',
 		name: 'server-rules',
 		component: page(() => import('./pages/admin/server-rules.vue')),
+	}, {
+		path: '/invites',
+		name: 'invites',
+		component: page(() => import('./pages/admin/invites.vue')),
 	}, {
 		path: '/',
 		component: page(() => import('./pages/_empty_.vue')),
diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts
index 53042a4ce7..aa2f7b9c55 100644
--- a/packages/frontend/src/ui/_common_/common.ts
+++ b/packages/frontend/src/ui/_common_/common.ts
@@ -33,7 +33,12 @@ export function openInstanceMenu(ev: MouseEvent) {
 		text: i18n.ts.ads,
 		icon: 'ti ti-ad',
 		to: '/ads',
-	}, {
+	}, ($i && ($i.isAdmin || $i.policies.canInvite) && instance.disableRegistration) ? {
+		type: 'link',
+		to: '/invite',
+		text: i18n.ts.invite,
+		icon: 'ti ti-user-plus',
+	} : undefined, {
 		type: 'parent',
 		text: i18n.ts.tools,
 		icon: 'ti ti-tool',
@@ -52,23 +57,7 @@ export function openInstanceMenu(ev: MouseEvent) {
 			to: '/clicker',
 			text: '🍪👈',
 			icon: 'ti ti-cookie',
-		}, ($i && ($i.isAdmin || $i.policies.canInvite) && instance.disableRegistration) ? {
-			text: i18n.ts.invite,
-			icon: 'ti ti-user-plus',
-			action: () => {
-				os.api('invite').then(x => {
-					os.alert({
-						type: 'info',
-						text: x.code,
-					});
-				}).catch(err => {
-					os.alert({
-						type: 'error',
-						text: err,
-					});
-				});
-			},
-		} : undefined, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? {
+		}, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? {
 			type: 'link',
 			to: '/custom-emojis-manager',
 			text: i18n.ts.manageCustomEmojis,
diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md
index c9b3fd6056..851b21abc5 100644
--- a/packages/misskey-js/etc/misskey-js.api.md
+++ b/packages/misskey-js/etc/misskey-js.api.md
@@ -481,6 +481,14 @@ export type Endpoints = {
         req: TODO;
         res: TODO;
     };
+    'admin/invite/create': {
+        req: TODO;
+        res: TODO;
+    };
+    'admin/invite/list': {
+        req: TODO;
+        res: TODO;
+    };
     'admin/moderators/add': {
         req: TODO;
         res: TODO;
@@ -1549,6 +1557,28 @@ export type Endpoints = {
         req: TODO;
         res: TODO;
     };
+    'invite/create': {
+        req: NoParams;
+        res: Invite;
+    };
+    'invite/delete': {
+        req: {
+            inviteId: Invite['id'];
+        };
+        res: null;
+    };
+    'invite/list': {
+        req: {
+            limit?: number;
+            sinceId?: Invite['id'];
+            untilId?: Invite['id'];
+        };
+        res: Invite[];
+    };
+    'invite/limit': {
+        req: NoParams;
+        res: InviteLimit;
+    };
     'messaging/history': {
         req: {
             limit?: number;
@@ -2210,6 +2240,8 @@ declare namespace entities {
         Blocking,
         Instance,
         Signin,
+        Invite,
+        InviteLimit,
         UserSorting,
         OriginType
     }
@@ -2310,6 +2342,23 @@ type Instance = {
 // @public (undocumented)
 type InstanceMetadata = LiteInstanceMetadata | DetailedInstanceMetadata;
 
+// @public (undocumented)
+type Invite = {
+    id: ID;
+    code: string;
+    expiresAt: DateString | null;
+    createdAt: DateString;
+    createdBy: UserLite | null;
+    usedBy: UserLite | null;
+    usedAt: DateString | null;
+    used: boolean;
+};
+
+// @public (undocumented)
+type InviteLimit = {
+    remaining: number;
+};
+
 // @public (undocumented)
 function isAPIError(reason: any): reason is APIError;
 
@@ -2756,7 +2805,7 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
 //
 // src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
 // src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
-// src/api.types.ts:620:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
+// src/api.types.ts:628:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
 // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
 
 // (No @packageDocumentation comment for this package)
diff --git a/packages/misskey-js/src/api.types.ts b/packages/misskey-js/src/api.types.ts
index 93f327e67e..ed12be4a06 100644
--- a/packages/misskey-js/src/api.types.ts
+++ b/packages/misskey-js/src/api.types.ts
@@ -2,7 +2,7 @@ import type {
 	Ad, Announcement, Antenna, App, AuthSession, Blocking, Channel, Clip, DateString, DetailedInstanceMetadata, DriveFile, DriveFolder, Following, FollowingFolloweePopulated, FollowingFollowerPopulated, FollowRequest, GalleryPost, Instance,
 	LiteInstanceMetadata,
 	MeDetailed,
-	Note, NoteFavorite, OriginType, Page, ServerInfo, Stats, User, UserDetailed, MeSignup, UserGroup, UserList, UserSorting, Notification, NoteReaction, Signin, MessagingMessage,
+	Note, NoteFavorite, OriginType, Page, ServerInfo, Stats, User, UserDetailed, MeSignup, UserGroup, UserList, UserSorting, Notification, NoteReaction, Signin, MessagingMessage, Invite, InviteLimit,
 } from './entities.js';
 
 type TODO = Record<string, any> | null;
@@ -57,6 +57,8 @@ export type Endpoints = {
 	'admin/federation/refresh-remote-instance-metadata': { req: TODO; res: TODO; };
 	'admin/federation/remove-all-following': { req: TODO; res: TODO; };
 	'admin/federation/update-instance': { req: TODO; res: TODO; };
+	'admin/invite/create': { req: TODO; res: TODO; };
+	'admin/invite/list': { req: TODO; res: TODO; };
 	'admin/moderators/add': { req: TODO; res: TODO; };
 	'admin/moderators/remove': { req: TODO; res: TODO; };
 	'admin/promo/create': { req: TODO; res: TODO; };
@@ -440,6 +442,12 @@ export type Endpoints = {
 	'i/2fa/remove-key': { req: TODO; res: TODO; };
 	'i/2fa/unregister': { req: TODO; res: TODO; };
 
+	// invite
+	'invite/create': { req: NoParams; res: Invite; };
+	'invite/delete': { req: { inviteId: Invite['id']; }; res: null; };
+	'invite/list': { req: { limit?: number; sinceId?: Invite['id']; untilId?: Invite['id'] }; res: Invite[]; };
+	'invite/limit': { req: NoParams; res: InviteLimit; };
+
 	// messaging
 	'messaging/history': { req: { limit?: number; group?: boolean; }; res: MessagingMessage[]; };
 	'messaging/messages': { req: { userId?: User['id']; groupId?: UserGroup['id']; limit?: number; sinceId?: MessagingMessage['id']; untilId?: MessagingMessage['id']; markAsRead?: boolean; }; res: MessagingMessage[]; };
diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts
index e1305452eb..9d8fd6a49b 100644
--- a/packages/misskey-js/src/entities.ts
+++ b/packages/misskey-js/src/entities.ts
@@ -516,6 +516,21 @@ export type Signin = {
 	success: boolean;
 };
 
+export type Invite = {
+	id: ID;
+	code: string;
+	expiresAt: DateString | null;
+	createdAt: DateString;
+	createdBy: UserLite | null;
+	usedBy: UserLite | null;
+	usedAt: DateString | null;
+	used: boolean;
+}
+
+export type InviteLimit = {
+	remaining: number;
+}
+
 export type UserSorting =
 	| '+follower'
 	| '-follower'
-- 
cgit v1.2.3-freya


From 07e96b734a5e3871dabff4be857fb242eac39159 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Mon, 17 Jul 2023 08:28:31 +0900
Subject: enhance(client): ロール設定画面でロールIDを確認できるように
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 CHANGELOG.md                                       | 1 +
 packages/frontend/src/pages/admin/roles.editor.vue | 4 ++++
 2 files changed, 5 insertions(+)

(limited to 'packages/frontend/src/pages/admin/roles.editor.vue')

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8a57b5ee11..2028a1883c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -37,6 +37,7 @@
 - 画面ビューワをタップした場合、マウスクリックと同様に画像ビューワを閉じるように
 - オフライン時の画面にリロードボタンを追加
 - Deckで非ルートページにアクセスした際に簡易UIで表示しない設定を追加
+- ロール設定画面でロールIDを確認できるように
 - Fix: サーバーメトリクスが90度傾いている
 - Fix: 非ログイン時にクレデンシャルが必要なページに行くとエラーが出る問題を修正
 - Fix: sparkle内にリンクを入れるとクリック不能になる問題の修正
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 7fe5624fb5..1ba502ff86 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -1,5 +1,9 @@
 <template>
 <div class="_gaps">
+	<MkInput v-if="readonly" :modelValue="role.id" :readonly="true">
+		<template #label>ID</template>
+	</MkInput>
+
 	<MkInput v-model="role.name" :readonly="readonly">
 		<template #label>{{ i18n.ts._role.name }}</template>
 	</MkInput>
-- 
cgit v1.2.3-freya

