diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ef7eab90a86..19e5155fb6e2 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 7555984b2406..e3ad4ed003b3 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 82efc8a46943..c66b42284da5 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 000000000000..0dd49f702704 --- /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 000000000000..d6b5c57f55dc --- /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 d3a1b1b024b3..c7c98b3bdd71 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 b0bfb44dc2e5..3b501cf8d741 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 000000000000..2d8e7a4681ee --- /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 000000000000..617b27361d18 --- /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 7579040c68e2..ec6bc4a5fbf5 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 139e40f85e4c..4c42b20be8c7 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 000000000000..b70a779f2914 --- /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 d1ff3fe9255b..4e6bc46e6778 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 5e18dcbe0860..d681bf8e21bf 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 94206ef870de..41c3a29eec02 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 000000000000..664b4d819f6b --- /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 000000000000..5d7a7f632c77 --- /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 000000000000..a64184be1035 --- /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 000000000000..afca44954de7 --- /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 276adcb07f8d..9a213b7b25ce 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 000000000000..e047790261ea --- /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 5fd21cdf0a45..a4289cff7da7 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 b3d7bd8f5e09..d47d8672c758 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 000000000000..def0a96e6ac4 --- /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 000000000000..fdde79b178a4 --- /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 ad7fa372e97c..1d883c038ea6 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 226eb8d02672..e91f65b5d57e 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 000000000000..70a9c9371305 --- /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 02a2d4366abe..7fe5624fb504 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -171,6 +171,65 @@ + + + +
+ + + + + + + + +
+
+ + + + +
+ + + + + + + + + +
+
+ + + + +
+ + + + + + + + + +
+
+