summaryrefslogtreecommitdiff
path: root/packages/frontend
diff options
context:
space:
mode:
authoryukineko <27853966+hideki0403@users.noreply.github.com>2023-07-15 09:57:58 +0900
committerGitHub <noreply@github.com>2023-07-15 09:57:58 +0900
commit02957a1b5daaaf821ce21c11cc47cf169c4fc535 (patch)
treeecaa8fee0d547bb3733551ccebf85281357b9feb /packages/frontend
parentfix(build): d.ts生成時にexport defaultを生成するように (#11280) (diff)
downloadmisskey-02957a1b5daaaf821ce21c11cc47cf169c4fc535.tar.gz
misskey-02957a1b5daaaf821ce21c11cc47cf169c4fc535.tar.bz2
misskey-02957a1b5daaaf821ce21c11cc47cf169c4fc535.zip
enhance: 招待機能の改善 (#11195)
* refactor(backend): 招待機能を改修 * feat(backend): 招待コードのcreate/delete/listエンドポイントを追加 * add(misskey-js): エンドポイントと型を追加 * change(backend): metaでinvite関連の情報も返すように * add(misskey-js): エンドポイントと型を追加 * add(backend): `/endpoints/invite/limit`を追加 * fix: createdByがnullableではなかったのを修正 * fix: relationが取得できていなかった問題を修正 * fix: パラメータを間違えていたのを修正 * feat(client): 招待ページを実装 * change(client): インスタンスメニューの「招待」押した場合に招待ページに飛ぶように変更 * feat: 招待コードをコピーできるように * change(backend): metaに招待コード発行に関する情報を持たせるのをやめる * feat: ロールごとに招待コードの発行上限数などを設定できるように * change(client): 招待コードをコピーしたときにダイアログを出すように * add: 招待に関する管理者用のエンドポイントを追加 * change(backend): モデレーターであれば作成者以外でも招待コードを削除できるように * change(backend): admin/invite/listはオフセットでページネーションするように * feat(client): 招待コードの管理ページを追加 * feat(client): 招待コードのリストをソートできるように * change: `admin/invite/create`のレスポンスを修正 * fix(client): 有効期限を指定できていなかった問題を修正 * refactor: 必要のない箇所を削除 * perf(backend): use limit() instead of take() * change(client): 作成ボタンを見た目を変更 * refactor: 招待コードの生成部分を共通化し、コード内に"01OI"のいずれかの文字を含まないように * fix(client): paginationの仕様が変わっていたので修正 * change(backend): expiresAtパラメータのnullを許容 * change(client): 有効期限を設けないときは日付の入力欄を非表示に * fix: 自身のポリシーよりもインスタンス側のポリシーが優先表示される問題を修正 * fix: n時間のときに「n時間間」となってしまうのを修正 * fix(backend): ポリシーが途中で変更されたときに作成可能数がマイナス表記になってしまうのを修正 * change(client): 招待コードのユーザー名が不明な理由を表示するように * update: CHANGELOG.md * lint * refactor * refactor * tweak ui * :art: * :art: * add(backend): indexを追加 * change(backend): indexの追加に伴う変更 * change(client): インスタンスメニューの「招待」の場所を変更 * add(frontend): MkInviteCode用のstorybookを追加 * Update misskey-js.api.md * fix(misskey-js): InviteのcreatedByの型が間違っていたのを修正 --------- Co-authored-by: syuilo <Syuilotan@yahoo.co.jp> Co-authored-by: tamaina <tamaina@hotmail.co.jp>
Diffstat (limited to 'packages/frontend')
-rw-r--r--packages/frontend/.storybook/fakes.ts24
-rw-r--r--packages/frontend/.storybook/generate.tsx1
-rw-r--r--packages/frontend/src/components/MkInviteCode.stories.impl.ts60
-rw-r--r--packages/frontend/src/components/MkInviteCode.vue123
-rw-r--r--packages/frontend/src/const.ts3
-rw-r--r--packages/frontend/src/pages/admin/index.vue11
-rw-r--r--packages/frontend/src/pages/admin/invites.vue126
-rw-r--r--packages/frontend/src/pages/admin/roles.editor.vue59
-rw-r--r--packages/frontend/src/pages/admin/roles.vue23
-rw-r--r--packages/frontend/src/pages/invite.vue114
-rw-r--r--packages/frontend/src/router.ts8
-rw-r--r--packages/frontend/src/ui/_common_/common.ts25
12 files changed, 556 insertions, 21 deletions
diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts
index 5fd21cdf0a..a4289cff7d 100644
--- a/packages/frontend/.storybook/fakes.ts
+++ b/packages/frontend/.storybook/fakes.ts
@@ -115,3 +115,27 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
url: null,
};
}
+
+export function inviteCode(isUsed = false, hasExpiration = false, isExpired = false, isCreatedBySystem = false) {
+ const date = new Date();
+ const createdAt = new Date();
+ createdAt.setDate(date.getDate() - 1)
+ const expiresAt = new Date();
+
+ if (isExpired) {
+ expiresAt.setHours(date.getHours() - 1)
+ } else {
+ expiresAt.setHours(date.getHours() + 1)
+ }
+
+ return {
+ id: "9gyqzizw77",
+ code: "SLF3JKF7UV2H9",
+ expiresAt: hasExpiration ? expiresAt.toISOString() : null,
+ createdAt: createdAt.toISOString(),
+ createdBy: isCreatedBySystem ? null : userDetailed('8i3rvznx32'),
+ usedBy: isUsed ? userDetailed('3i3r2znx1v') : null,
+ usedAt: isUsed ? date.toISOString() : null,
+ used: isUsed,
+ }
+}
diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx
index b3d7bd8f5e..d47d8672c7 100644
--- a/packages/frontend/.storybook/generate.tsx
+++ b/packages/frontend/.storybook/generate.tsx
@@ -403,6 +403,7 @@ function toStories(component: string): Promise<string> {
glob('src/components/MkSignupServerRules.vue'),
glob('src/components/MkUserSetupDialog.vue'),
glob('src/components/MkUserSetupDialog.*.vue'),
+ glob('src/components/MkInviteCode.vue'),
glob('src/pages/user/home.vue'),
]);
const components = globs.flat();
diff --git a/packages/frontend/src/components/MkInviteCode.stories.impl.ts b/packages/frontend/src/components/MkInviteCode.stories.impl.ts
new file mode 100644
index 0000000000..def0a96e6a
--- /dev/null
+++ b/packages/frontend/src/components/MkInviteCode.stories.impl.ts
@@ -0,0 +1,60 @@
+/* eslint-disable @typescript-eslint/explicit-function-return-type */
+import { StoryObj } from '@storybook/vue3';
+import { rest } from 'msw';
+import { userDetailed, inviteCode } from '../../.storybook/fakes';
+import { commonHandlers } from '../../.storybook/mocks';
+import MkInviteCode from './MkInviteCode.vue';
+
+export const Default = {
+ render(args) {
+ return {
+ components: {
+ MkInviteCode,
+ },
+ setup() {
+ return {
+ args,
+ };
+ },
+ computed: {
+ props() {
+ return {
+ ...this.args,
+ };
+ },
+ },
+ template: '<MkInviteCode v-bind="props" />',
+ };
+ },
+ args: {
+ invite: inviteCode() as any,
+ },
+ parameters: {
+ layout: 'centered',
+ msw: {
+ handlers: [
+ ...commonHandlers,
+ rest.post('/api/users/show', (req, res, ctx) => {
+ return res(ctx.json(userDetailed(req.params.userId as string)));
+ }),
+ ],
+ },
+ },
+ decorators: [() => ({
+ template: '<div style="width:100cqmin"><story/></div>',
+ })],
+} satisfies StoryObj<typeof MkInviteCode>;
+
+export const Used = {
+ ...Default,
+ args: {
+ invite: inviteCode(true) as any
+ },
+} satisfies StoryObj<typeof MkInviteCode>;
+
+export const Expired = {
+ ...Default,
+ args: {
+ invite: inviteCode(false, true, true) as any
+ },
+} satisfies StoryObj<typeof MkInviteCode>;
diff --git a/packages/frontend/src/components/MkInviteCode.vue b/packages/frontend/src/components/MkInviteCode.vue
new file mode 100644
index 0000000000..fdde79b178
--- /dev/null
+++ b/packages/frontend/src/components/MkInviteCode.vue
@@ -0,0 +1,123 @@
+<template>
+<MkFolder>
+ <template #label>{{ invite.code }}</template>
+ <template #suffix>
+ <span v-if="invite.used">{{ i18n.ts.used }}</span>
+ <span v-else-if="isExpired" style="color: var(--error)">{{ i18n.ts.expired }}</span>
+ <span v-else style="color: var(--success)">{{ i18n.ts.unused }}</span>
+ </template>
+
+ <div class="_gaps_s" :class="$style.root">
+ <div :class="$style.items">
+ <div>
+ <div :class="$style.label">{{ i18n.ts.invitationCode }}</div>
+ <div>{{ invite.code }}</div>
+ </div>
+ <div v-if="moderator">
+ <div :class="$style.label">{{ i18n.ts.inviteCodeCreator }}</div>
+ <div v-if="invite.createdBy" :class="$style.user">
+ <MkAvatar :user="invite.createdBy" :class="$style.avatar" link preview/>
+ <MkUserName :user="invite.createdBy" :nowrap="false"/>
+ <div v-if="moderator">({{ invite.createdBy.id }})</div>
+ </div>
+ <div v-else>system</div>
+ </div>
+ <div v-if="invite.used">
+ <div :class="$style.label">{{ i18n.ts.registeredUserUsingInviteCode }}</div>
+ <div v-if="invite.usedBy" :class="$style.user">
+ <MkAvatar :user="invite.usedBy" :class="$style.avatar" link preview/>
+ <MkUserName :user="invite.usedBy" :nowrap="false"/>
+ <div v-if="moderator">({{ invite.usedBy.id }})</div>
+ </div>
+ <div v-else>{{ i18n.ts.unknown }} ({{ i18n.ts.waitingForMailAuth }})</div>
+ </div>
+ <div v-if="invite.expiresAt && !invite.used">
+ <div :class="$style.label">{{ i18n.ts.expirationDate }}</div>
+ <div><MkTime :time="invite.expiresAt" mode="absolute"/></div>
+ </div>
+ <div v-if="invite.usedAt">
+ <div :class="$style.label">{{ i18n.ts.inviteCodeUsedAt }}</div>
+ <div><MkTime :time="invite.usedAt" mode="absolute"/></div>
+ </div>
+ <div v-if="moderator">
+ <div :class="$style.label">{{ i18n.ts.createdAt }}</div>
+ <div><MkTime :time="invite.createdAt" mode="absolute"/></div>
+ </div>
+ </div>
+ <div :class="$style.buttons">
+ <MkButton v-if="!invite.used && !isExpired" primary rounded @click="copyInviteCode()">{{ i18n.ts.copy }}</MkButton>
+ <MkButton v-if="!invite.used || moderator" danger rounded @click="deleteCode()">{{ i18n.ts.delete }}</MkButton>
+ </div>
+ </div>
+</MkFolder>
+</template>
+
+<script lang="ts" setup>
+import { computed } from 'vue';
+import * as misskey from 'misskey-js';
+import MkFolder from '@/components/MkFolder.vue';
+import MkButton from '@/components/MkButton.vue';
+import copyToClipboard from '@/scripts/copy-to-clipboard';
+import { i18n } from '@/i18n';
+import * as os from '@/os';
+
+const props = defineProps<{
+ invite: misskey.entities.Invite;
+ moderator?: boolean;
+}>();
+
+const emits = defineEmits<{
+ (event: 'deleted', value: string): void;
+}>();
+
+const isExpired = computed(() => {
+ return props.invite.expiresAt && new Date(props.invite.expiresAt) < new Date();
+});
+
+function deleteCode() {
+ os.apiWithDialog('invite/delete', {
+ inviteId: props.invite.id,
+ });
+ emits('deleted', props.invite.id);
+}
+
+function copyInviteCode() {
+ copyToClipboard(props.invite.code);
+ os.success();
+}
+</script>
+
+<style lang="scss" module>
+.root {
+ text-align: left;
+}
+
+.items {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ grid-gap: 12px;
+}
+
+.label {
+ font-size: 0.85em;
+ padding: 0 0 8px 0;
+ user-select: none;
+}
+
+.user {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.avatar {
+ --height: 24px;
+ width: var(--height);
+ height: var(--height);
+}
+
+.buttons {
+ display: flex;
+ gap: 8px;
+}
+</style>
diff --git a/packages/frontend/src/const.ts b/packages/frontend/src/const.ts
index ad7fa372e9..1d883c038e 100644
--- a/packages/frontend/src/const.ts
+++ b/packages/frontend/src/const.ts
@@ -57,6 +57,9 @@ export const ROLE_POLICIES = [
'ltlAvailable',
'canPublicNote',
'canInvite',
+ 'inviteLimit',
+ 'inviteLimitCycle',
+ 'inviteExpirationTime',
'canManageCustomEmojis',
'canSearchNotes',
'canHideAds',
diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue
index 226eb8d026..e91f65b5d5 100644
--- a/packages/frontend/src/pages/admin/index.vue
+++ b/packages/frontend/src/pages/admin/index.vue
@@ -80,7 +80,7 @@ const menuDef = $computed(() => [{
}, ...(instance.disableRegistration ? [{
type: 'button',
icon: 'ti ti-user-plus',
- text: i18n.ts.invite,
+ text: i18n.ts.createInviteCode,
action: invite,
}] : [])],
}, {
@@ -96,6 +96,11 @@ const menuDef = $computed(() => [{
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,
to: '/admin/roles',
@@ -240,10 +245,10 @@ provideMetadataReceiver((info) => {
});
const invite = () => {
- os.api('invite').then(x => {
+ os.api('admin/invite/create').then(x => {
os.alert({
type: 'info',
- text: x.code,
+ text: x?.[0].code,
});
}).catch(err => {
os.alert({
diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue
new file mode 100644
index 0000000000..70a9c93713
--- /dev/null
+++ b/packages/frontend/src/pages/admin/invites.vue
@@ -0,0 +1,126 @@
+<template>
+<MkStickyContainer>
+ <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :contentMax="800">
+ <div class="_gaps_m">
+ <MkFolder :expanded="false">
+ <template #icon><i class="ti ti-plus"></i></template>
+ <template #label>{{ i18n.ts.createInviteCode }}</template>
+
+ <div class="_gaps_m">
+ <MkSwitch v-model="noExpirationDate">
+ <template #label>{{ i18n.ts.noExpirationDate }}</template>
+ </MkSwitch>
+ <MkInput v-if="!noExpirationDate" v-model="expiresAt" type="datetime-local">
+ <template #label>{{ i18n.ts.expirationDate }}</template>
+ </MkInput>
+ <MkInput v-model="createCount" type="number">
+ <template #label>{{ i18n.ts.createCount }}</template>
+ </MkInput>
+ <MkButton primary rounded @click="createWithOptions">{{ i18n.ts.create }}</MkButton>
+ </div>
+ </MkFolder>
+
+ <div :class="$style.inputs">
+ <MkSelect v-model="type" :class="$style.input">
+ <template #label>{{ i18n.ts.state }}</template>
+ <option value="all">{{ i18n.ts.all }}</option>
+ <option value="unused">{{ i18n.ts.unused }}</option>
+ <option value="used">{{ i18n.ts.used }}</option>
+ <option value="expired">{{ i18n.ts.expired }}</option>
+ </MkSelect>
+ <MkSelect v-model="sort" :class="$style.input">
+ <template #label>{{ i18n.ts.sort }}</template>
+ <option value="+createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.ascendingOrder }})</option>
+ <option value="-createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.descendingOrder }})</option>
+ <option value="+usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.ascendingOrder }})</option>
+ <option value="-usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.descendingOrder }})</option>
+ </MkSelect>
+ </div>
+ <MkPagination ref="pagingComponent" :pagination="pagination">
+ <template #default="{ items }">
+ <div class="_gaps_s">
+ <MkInviteCode v-for="item in items" :key="item.id" :invite="(item as any)" :onDeleted="deleted" moderator/>
+ </div>
+ </template>
+ </MkPagination>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, shallowRef } from 'vue';
+import XHeader from './_header_.vue';
+import { i18n } from '@/i18n';
+import * as os from '@/os';
+import MkButton from '@/components/MkButton.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkSelect from '@/components/MkSelect.vue';
+import MkInput from '@/components/MkInput.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import MkPagination, { Paging } from '@/components/MkPagination.vue';
+import MkInviteCode from '@/components/MkInviteCode.vue';
+import { definePageMetadata } from '@/scripts/page-metadata';
+
+const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
+
+let type = ref('all');
+let sort = ref('+createdAt');
+
+const pagination: Paging = {
+ endpoint: 'admin/invite/list' as const,
+ limit: 10,
+ params: computed(() => ({
+ type: type.value,
+ sort: sort.value,
+ })),
+ offsetMode: true,
+};
+
+const expiresAt = ref('');
+const noExpirationDate = ref(true);
+const createCount = ref(1);
+
+async function createWithOptions() {
+ const options = {
+ expiresAt: noExpirationDate.value ? null : expiresAt.value,
+ count: createCount.value,
+ };
+
+ const tickets = await os.api('admin/invite/create', options);
+ os.alert({
+ type: 'success',
+ title: i18n.ts.inviteCodeCreated,
+ text: tickets?.map(x => x.code).join('\n'),
+ });
+
+ tickets?.forEach(ticket => pagingComponent.value?.prepend(ticket));
+}
+
+function deleted(id: string) {
+ if (pagingComponent.value) {
+ pagingComponent.value.items.delete(id);
+ }
+}
+
+const headerActions = $computed(() => []);
+const headerTabs = $computed(() => []);
+
+definePageMetadata({
+ title: i18n.ts.invite,
+ icon: 'ti ti-user-plus',
+});
+</script>
+
+<style lang="scss" module>
+.inputs {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.input {
+ flex: 1;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue
index 02a2d4366a..7fe5624fb5 100644
--- a/packages/frontend/src/pages/admin/roles.editor.vue
+++ b/packages/frontend/src/pages/admin/roles.editor.vue
@@ -171,6 +171,65 @@
</div>
</MkFolder>
+ <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimit, 'inviteLimit'])">
+ <template #label>{{ i18n.ts._role._options.inviteLimit }}</template>
+ <template #suffix>
+ <span v-if="role.policies.inviteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.inviteLimit.value }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.inviteLimit)"></i></span>
+ </template>
+ <div class="_gaps">
+ <MkSwitch v-model="role.policies.inviteLimit.useDefault" :readonly="readonly">
+ <template #label>{{ i18n.ts._role.useBaseValue }}</template>
+ </MkSwitch>
+ <MkInput v-model="role.policies.inviteLimit.value" :disabled="role.policies.inviteLimit.useDefault" type="number" :readonly="readonly">
+ </MkInput>
+ <MkRange v-model="role.policies.inviteLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <template #label>{{ i18n.ts._role.priority }}</template>
+ </MkRange>
+ </div>
+ </MkFolder>
+
+ <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimitCycle, 'inviteLimitCycle'])">
+ <template #label>{{ i18n.ts._role._options.inviteLimitCycle }}</template>
+ <template #suffix>
+ <span v-if="role.policies.inviteLimitCycle.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.inviteLimitCycle.value + i18n.ts._time.minute }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.inviteLimitCycle)"></i></span>
+ </template>
+ <div class="_gaps">
+ <MkSwitch v-model="role.policies.inviteLimitCycle.useDefault" :readonly="readonly">
+ <template #label>{{ i18n.ts._role.useBaseValue }}</template>
+ </MkSwitch>
+ <MkInput v-model="role.policies.inviteLimitCycle.value" :disabled="role.policies.inviteLimitCycle.useDefault" type="number" :readonly="readonly">
+ <template #suffix>{{ i18n.ts._time.minute }}</template>
+ </MkInput>
+ <MkRange v-model="role.policies.inviteLimitCycle.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <template #label>{{ i18n.ts._role.priority }}</template>
+ </MkRange>
+ </div>
+ </MkFolder>
+
+ <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteExpirationTime, 'inviteExpirationTime'])">
+ <template #label>{{ i18n.ts._role._options.inviteExpirationTime }}</template>
+ <template #suffix>
+ <span v-if="role.policies.inviteExpirationTime.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
+ <span v-else>{{ role.policies.inviteExpirationTime.value + i18n.ts._time.minute }}</span>
+ <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.inviteExpirationTime)"></i></span>
+ </template>
+ <div class="_gaps">
+ <MkSwitch v-model="role.policies.inviteExpirationTime.useDefault" :readonly="readonly">
+ <template #label>{{ i18n.ts._role.useBaseValue }}</template>
+ </MkSwitch>
+ <MkInput v-model="role.policies.inviteExpirationTime.value" :disabled="role.policies.inviteExpirationTime.useDefault" type="number" :readonly="readonly">
+ <template #suffix>{{ i18n.ts._time.minute }}</template>
+ </MkInput>
+ <MkRange v-model="role.policies.inviteExpirationTime.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
+ <template #label>{{ i18n.ts._role.priority }}</template>
+ </MkRange>
+ </div>
+ </MkFolder>
+
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])">
<template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template>
<template #suffix>
diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue
index 6634d9cba9..cdb6e90505 100644
--- a/packages/frontend/src/pages/admin/roles.vue
+++ b/packages/frontend/src/pages/admin/roles.vue
@@ -51,6 +51,29 @@
</MkSwitch>
</MkFolder>
+ <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimit, 'inviteLimit'])">
+ <template #label>{{ i18n.ts._role._options.inviteLimit }}</template>
+ <template #suffix>{{ policies.inviteLimit }}</template>
+ <MkInput v-model="policies.inviteLimit" type="number">
+ </MkInput>
+ </MkFolder>
+
+ <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimitCycle, 'inviteLimitCycle'])">
+ <template #label>{{ i18n.ts._role._options.inviteLimitCycle }}</template>
+ <template #suffix>{{ policies.inviteLimitCycle + i18n.ts._time.minute }}</template>
+ <MkInput v-model="policies.inviteLimitCycle" type="number">
+ <template #suffix>{{ i18n.ts._time.minute }}</template>
+ </MkInput>
+ </MkFolder>
+
+ <MkFolder v-if="matchQuery([i18n.ts._role._options.inviteExpirationTime, 'inviteExpirationTime'])">
+ <template #label>{{ i18n.ts._role._options.inviteExpirationTime }}</template>
+ <template #suffix>{{ policies.inviteExpirationTime + i18n.ts._time.minute }}</template>
+ <MkInput v-model="policies.inviteExpirationTime" type="number">
+ <template #suffix>{{ i18n.ts._time.minute }}</template>
+ </MkInput>
+ </MkFolder>
+
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])">
<template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template>
<template #suffix>{{ policies.canManageCustomEmojis ? i18n.ts.yes : i18n.ts.no }}</template>
diff --git a/packages/frontend/src/pages/invite.vue b/packages/frontend/src/pages/invite.vue
new file mode 100644
index 0000000000..c893ad51e8
--- /dev/null
+++ b/packages/frontend/src/pages/invite.vue
@@ -0,0 +1,114 @@
+<template>
+<MkStickyContainer>
+ <template #header>
+ <MkPageHeader/>
+ </template>
+ <MKSpacer v-if="!instance.disableRegistration || !($i && ($i.isAdmin || $i.policies.canInvite))" :contentMax="1200">
+ <div :class="$style.root">
+ <img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/>
+ <div :class="$style.text">
+ <i class="ti ti-alert-triangle"></i>
+ {{ i18n.ts.nothing }}
+ </div>
+ </div>
+ </MKSpacer>
+ <MkSpacer v-else :contentMax="800">
+ <div class="_gaps_m" style="text-align: center;">
+ <div v-if="resetCycle && inviteLimit">{{ i18n.t('inviteLimitResetCycle', { time: resetCycle, limit: inviteLimit }) }}</div>
+ <MkButton inline primary rounded :disabled="currentInviteLimit !== null && currentInviteLimit <= 0" @click="create"><i class="ti ti-user-plus"></i> {{ i18n.ts.createInviteCode }}</MkButton>
+ <div v-if="currentInviteLimit !== null">{{ i18n.t('createLimitRemaining', { limit: currentInviteLimit }) }}</div>
+
+ <MkPagination ref="pagingComponent" :pagination="pagination">
+ <template #default="{ items }">
+ <div class="_gaps_s">
+ <MkInviteCode v-for="item in (items as Invite[])" :key="item.id" :invite="item" :onDeleted="deleted"/>
+ </div>
+ </template>
+ </MkPagination>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, shallowRef } from 'vue';
+import type { Invite } from 'misskey-js/built/entities';
+import { i18n } from '@/i18n';
+import * as os from '@/os';
+import MkButton from '@/components/MkButton.vue';
+import MkPagination, { Paging } from '@/components/MkPagination.vue';
+import MkInviteCode from '@/components/MkInviteCode.vue';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { serverErrorImageUrl, instance } from '@/instance';
+import { $i } from '@/account';
+
+const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
+const currentInviteLimit = ref<null | number>(null);
+const inviteLimit = (($i != null && $i.policies.inviteLimit) || (($i == null && instance.policies.inviteLimit))) as number;
+const inviteLimitCycle = (($i != null && $i.policies.inviteLimitCycle) || ($i == null && instance.policies.inviteLimitCycle)) as number;
+
+const pagination: Paging = {
+ endpoint: 'invite/list' as const,
+ limit: 10,
+};
+
+const resetCycle = computed<null | string>(() => {
+ if (!inviteLimitCycle) return null;
+
+ const minutes = inviteLimitCycle;
+ if (minutes < 60) return minutes + i18n.ts._time.minute;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return hours + i18n.ts._time.hour;
+ return Math.floor(hours / 24) + i18n.ts._time.day;
+});
+
+async function create() {
+ const ticket = await os.api('invite/create');
+ os.alert({
+ type: 'success',
+ title: i18n.ts.inviteCodeCreated,
+ text: ticket.code,
+ });
+
+ pagingComponent.value?.prepend(ticket);
+ update();
+}
+
+function deleted(id: string) {
+ if (pagingComponent.value) {
+ pagingComponent.value.items.delete(id);
+ }
+ update();
+}
+
+async function update() {
+ currentInviteLimit.value = (await os.api('invite/limit')).remaining;
+}
+
+update();
+
+definePageMetadata({
+ title: i18n.ts.invite,
+ icon: 'ti ti-user-plus',
+});
+</script>
+
+<style lang="scss" module>
+.root {
+ padding: 32px;
+ text-align: center;
+ align-items: center;
+}
+
+.text {
+ margin: 0 0 8px 0;
+}
+
+.img {
+ vertical-align: bottom;
+ width: 128px;
+ height: 128px;
+ margin-bottom: 16px;
+ border-radius: 16px;
+}
+</style>
diff --git a/packages/frontend/src/router.ts b/packages/frontend/src/router.ts
index 873b372e0a..a4276ff4c0 100644
--- a/packages/frontend/src/router.ts
+++ b/packages/frontend/src/router.ts
@@ -202,6 +202,10 @@ export const routes = [{
path: '/about-misskey',
component: page(() => import('./pages/about-misskey.vue')),
}, {
+ path: '/invite',
+ name: 'invite',
+ component: page(() => import('./pages/invite.vue')),
+}, {
path: '/ads',
component: page(() => import('./pages/ads.vue')),
}, {
@@ -429,6 +433,10 @@ export const routes = [{
name: 'server-rules',
component: page(() => import('./pages/admin/server-rules.vue')),
}, {
+ path: '/invites',
+ name: 'invites',
+ component: page(() => import('./pages/admin/invites.vue')),
+ }, {
path: '/',
component: page(() => import('./pages/_empty_.vue')),
}],
diff --git a/packages/frontend/src/ui/_common_/common.ts b/packages/frontend/src/ui/_common_/common.ts
index 53042a4ce7..aa2f7b9c55 100644
--- a/packages/frontend/src/ui/_common_/common.ts
+++ b/packages/frontend/src/ui/_common_/common.ts
@@ -33,7 +33,12 @@ export function openInstanceMenu(ev: MouseEvent) {
text: i18n.ts.ads,
icon: 'ti ti-ad',
to: '/ads',
- }, {
+ }, ($i && ($i.isAdmin || $i.policies.canInvite) && instance.disableRegistration) ? {
+ type: 'link',
+ to: '/invite',
+ text: i18n.ts.invite,
+ icon: 'ti ti-user-plus',
+ } : undefined, {
type: 'parent',
text: i18n.ts.tools,
icon: 'ti ti-tool',
@@ -52,23 +57,7 @@ export function openInstanceMenu(ev: MouseEvent) {
to: '/clicker',
text: '🍪👈',
icon: 'ti ti-cookie',
- }, ($i && ($i.isAdmin || $i.policies.canInvite) && instance.disableRegistration) ? {
- text: i18n.ts.invite,
- icon: 'ti ti-user-plus',
- action: () => {
- os.api('invite').then(x => {
- os.alert({
- type: 'info',
- text: x.code,
- });
- }).catch(err => {
- os.alert({
- type: 'error',
- text: err,
- });
- });
- },
- } : undefined, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? {
+ }, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? {
type: 'link',
to: '/custom-emojis-manager',
text: i18n.ts.manageCustomEmojis,