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: [PATCH] =?UTF-8?q?enhance:=20=E6=8B=9B=E5=BE=85=E6=A9=9F=E8=83=BD?= =?UTF-8?q?=E3=81=AE=E6=94=B9=E5=96=84=20(#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 Co-authored-by: tamaina --- 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 ++ .../core/entities/InviteCodeEntityService.ts | 52 ++++++++ .../backend/src/misc/generate-invite-code.ts | 20 +++ packages/backend/src/misc/json-schema.ts | 2 + .../src/models/entities/RegistrationTicket.ts | 51 ++++++- .../src/models/json-schema/invite-code.ts | 45 +++++++ .../backend/src/server/api/EndpointsModule.ts | 28 +++- .../src/server/api/SignupApiService.ts | 44 +++++- packages/backend/src/server/api/endpoints.ts | 14 +- .../api/endpoints/admin/invite/create.ts | 80 +++++++++++ .../server/api/endpoints/admin/invite/list.ts | 70 ++++++++++ .../src/server/api/endpoints/invite/create.ts | 82 ++++++++++++ .../src/server/api/endpoints/invite/delete.ts | 71 ++++++++++ .../endpoints/{invite.ts => invite/limit.ts} | 30 ++--- .../src/server/api/endpoints/invite/list.ts | 58 ++++++++ packages/frontend/.storybook/fakes.ts | 24 ++++ packages/frontend/.storybook/generate.tsx | 1 + .../components/MkInviteCode.stories.impl.ts | 60 +++++++++ .../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 ++++++++++++++++++ .../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 +++ 36 files changed, 1341 insertions(+), 56 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 create mode 100644 packages/backend/src/server/api/endpoints/invite/create.ts create mode 100644 packages/backend/src/server/api/endpoints/invite/delete.ts rename packages/backend/src/server/api/endpoints/{invite.ts => invite/limit.ts} (60%) 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 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> { + 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:
${link}`, `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 { + 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 { + 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/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 { + 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 { + 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.ts b/packages/backend/src/server/api/endpoints/invite/limit.ts similarity index 60% rename from packages/backend/src/server/api/endpoints/invite.ts rename to packages/backend/src/server/api/endpoints/invite/limit.ts index 276adcb07f..9a213b7b25 100644 --- a/packages/backend/src/server/api/endpoints/invite.ts +++ b/packages/backend/src/server/api/endpoints/invite/limit.ts @@ -1,9 +1,9 @@ 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 { IdService } from '@/core/IdService.js'; +import { RoleService } from '@/core/RoleService.js'; import { DI } from '@/di-symbols.js'; -import { secureRndstr } from '@/misc/secure-rndstr.js'; export const meta = { tags: ['meta'], @@ -15,12 +15,9 @@ export const meta = { type: 'object', optional: false, nullable: false, properties: { - code: { - type: 'string', - optional: false, nullable: false, - example: '2ERUA5VR', - maxLength: 8, - minLength: 8, + remaining: { + type: 'integer', + optional: false, nullable: true, }, }, }, @@ -39,21 +36,18 @@ export default class extends Endpoint { @Inject(DI.registrationTicketsRepository) private registrationTicketsRepository: RegistrationTicketsRepository, - private idService: IdService, + private roleService: RoleService, ) { super(meta, paramDef, async (ps, me) => { - const code = secureRndstr(8, { - chars: '23456789ABCDEFGHJKLMNPQRSTUVWXYZ', // [0-9A-Z] w/o [01IO] (32 patterns) - }); + const policies = await this.roleService.getUserPolicies(me.id); - await this.registrationTicketsRepository.insert({ - id: this.idService.genId(), - createdAt: new Date(), - code, - }); + const count = policies.inviteLimit ? await this.registrationTicketsRepository.countBy({ + createdAt: MoreThan(new Date(Date.now() - (policies.inviteExpirationTime * 60 * 1000))), + createdById: me.id, + }) : null; return { - code, + 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 { + 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 { 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: '', + }; + }, + 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: '
', + })], +} satisfies StoryObj; + +export const Used = { + ...Default, + args: { + invite: inviteCode(true) as any + }, +} satisfies StoryObj; + +export const Expired = { + ...Default, + args: { + invite: inviteCode(false, true, true) as any + }, +} satisfies StoryObj; 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 @@ + + + + + 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 @@ + + + + + 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 @@ + + + +
+ + + + + + + + +
+
+ + + + +
+ + + + + + + + + +
+
+ + + + +
+ + + + + + + + + +
+
+