summaryrefslogtreecommitdiff
path: root/packages/frontend/src
diff options
context:
space:
mode:
authorおさむのひと <46447427+samunohito@users.noreply.github.com>2024-06-08 15:34:19 +0900
committerGitHub <noreply@github.com>2024-06-08 15:34:19 +0900
commit61fae45390283aee7ac582aa5303aae863de0f7a (patch)
tree17182172ef9f932182fc55f2aabd7243d2be66b2 /packages/frontend/src
parent配信停止したインスタンス一覧が見れなくなる問題を修... (diff)
downloadmisskey-61fae45390283aee7ac582aa5303aae863de0f7a.tar.gz
misskey-61fae45390283aee7ac582aa5303aae863de0f7a.tar.bz2
misskey-61fae45390283aee7ac582aa5303aae863de0f7a.zip
feat: 通報を受けた際にメールまたはWebhookで通知を送出出来るようにする (#13758)
* feat: 通報を受けた際にメールまたはWebhookで通知を送出出来るようにする * モデログに対応&エンドポイントを単一オブジェクトでのサポートに変更(API経由で大量に作るシチュエーションもないと思うので) * fix spdx * fix migration * fix migration * fix models * add e2e webhook * tweak * fix modlog * fix bugs * add tests and fix bugs * add tests and fix bugs * add tests * fix path * regenerate locale * 混入除去 * 混入除去 * add abuseReportResolved * fix pnpm-lock.yaml * add abuseReportResolved test * fix bugs * fix ui * add tests * fix CHANGELOG.md * add tests * add RoleService.getModeratorIds tests * WebhookServiceをUserとSystemに分割 * fix CHANGELOG.md * fix test * insertOneを使う用に * fix * regenerate locales * revert version * separate webhook job queue * fix * :art: * Update QueueProcessorService.ts --------- Co-authored-by: osamu <46447427+sam-osamu@users.noreply.github.com> Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
Diffstat (limited to 'packages/frontend/src')
-rw-r--r--packages/frontend/src/components/MkButton.vue2
-rw-r--r--packages/frontend/src/components/MkDivider.vue32
-rw-r--r--packages/frontend/src/components/MkSwitch.vue5
-rw-r--r--packages/frontend/src/components/MkSystemWebhookEditor.impl.ts45
-rw-r--r--packages/frontend/src/components/MkSystemWebhookEditor.vue217
-rw-r--r--packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue307
-rw-r--r--packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue114
-rw-r--r--packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue176
-rw-r--r--packages/frontend/src/pages/admin/abuses.vue85
-rw-r--r--packages/frontend/src/pages/admin/index.vue5
-rw-r--r--packages/frontend/src/pages/admin/modlog.ModLog.vue48
-rw-r--r--packages/frontend/src/pages/admin/system-webhook.item.vue117
-rw-r--r--packages/frontend/src/pages/admin/system-webhook.vue96
-rw-r--r--packages/frontend/src/router/definition.ts8
14 files changed, 1223 insertions, 34 deletions
diff --git a/packages/frontend/src/components/MkButton.vue b/packages/frontend/src/components/MkButton.vue
index 3489255b91..25b003ba5a 100644
--- a/packages/frontend/src/components/MkButton.vue
+++ b/packages/frontend/src/components/MkButton.vue
@@ -11,6 +11,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:type="type"
:name="name"
:value="value"
+ :disabled="disabled"
@click="emit('click', $event)"
@mousedown="onMousedown"
>
@@ -55,6 +56,7 @@ const props = defineProps<{
asLike?: boolean;
name?: string;
value?: string;
+ disabled?: boolean;
}>();
const emit = defineEmits<{
diff --git a/packages/frontend/src/components/MkDivider.vue b/packages/frontend/src/components/MkDivider.vue
new file mode 100644
index 0000000000..e4e3af99e4
--- /dev/null
+++ b/packages/frontend/src/components/MkDivider.vue
@@ -0,0 +1,32 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div
+ class="default" :style="[
+ marginTopBottom ? { marginTop: marginTopBottom, marginBottom: marginTopBottom } : {},
+ marginLeftRight ? { marginLeft: marginLeftRight, marginRight: marginLeftRight } : {},
+ borderStyle ? { borderStyle: borderStyle } : {},
+ borderWidth ? { borderWidth: borderWidth } : {},
+ borderColor ? { borderColor: borderColor } : {},
+ ]"
+/>
+</template>
+
+<script setup lang="ts">
+defineProps<{
+ marginTopBottom?: string;
+ marginLeftRight?: string;
+ borderStyle?: string;
+ borderWidth?: string;
+ borderColor?: string;
+}>();
+</script>
+
+<style scoped lang="scss">
+.default {
+ border-top: solid 0.5px var(--divider);
+}
+</style>
diff --git a/packages/frontend/src/components/MkSwitch.vue b/packages/frontend/src/components/MkSwitch.vue
index a19b45448b..721ac357f4 100644
--- a/packages/frontend/src/components/MkSwitch.vue
+++ b/packages/frontend/src/components/MkSwitch.vue
@@ -13,7 +13,7 @@ SPDX-License-Identifier: AGPL-3.0-only
@keydown.enter="toggle"
>
<XButton :checked="checked" :disabled="disabled" @toggle="toggle"/>
- <span :class="$style.body">
+ <span v-if="!noBody" :class="$style.body">
<!-- TODO: 無名slotの方は廃止 -->
<span :class="$style.label">
<span @click="toggle">
@@ -34,16 +34,19 @@ const props = defineProps<{
modelValue: boolean | Ref<boolean>;
disabled?: boolean;
helpText?: string;
+ noBody?: boolean;
}>();
const emit = defineEmits<{
(ev: 'update:modelValue', v: boolean): void;
+ (ev: 'change', v: boolean): void;
}>();
const checked = toRefs(props).modelValue;
const toggle = () => {
if (props.disabled) return;
emit('update:modelValue', !checked.value);
+ emit('change', !checked.value);
};
</script>
diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.impl.ts b/packages/frontend/src/components/MkSystemWebhookEditor.impl.ts
new file mode 100644
index 0000000000..1222d3261d
--- /dev/null
+++ b/packages/frontend/src/components/MkSystemWebhookEditor.impl.ts
@@ -0,0 +1,45 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { defineAsyncComponent } from 'vue';
+import * as os from '@/os.js';
+
+export type SystemWebhookEventType = 'abuseReport' | 'abuseReportResolved';
+
+export type MkSystemWebhookEditorProps = {
+ mode: 'create' | 'edit';
+ id?: string;
+ requiredEvents?: SystemWebhookEventType[];
+};
+
+export type MkSystemWebhookResult = {
+ id?: string;
+ isActive: boolean;
+ name: string;
+ on: SystemWebhookEventType[];
+ url: string;
+ secret: string;
+};
+
+export async function showSystemWebhookEditorDialog(props: MkSystemWebhookEditorProps): Promise<MkSystemWebhookResult | null> {
+ const { dispose, result } = await new Promise<{ dispose: () => void, result: MkSystemWebhookResult | null }>(async resolve => {
+ const res = await os.popup(
+ defineAsyncComponent(() => import('@/components/MkSystemWebhookEditor.vue')),
+ props,
+ {
+ submitted: (ev: MkSystemWebhookResult) => {
+ resolve({ dispose: res.dispose, result: ev });
+ },
+ closed: () => {
+ resolve({ dispose: res.dispose, result: null });
+ },
+ },
+ );
+ });
+
+ dispose();
+
+ return result;
+}
diff --git a/packages/frontend/src/components/MkSystemWebhookEditor.vue b/packages/frontend/src/components/MkSystemWebhookEditor.vue
new file mode 100644
index 0000000000..007d841f00
--- /dev/null
+++ b/packages/frontend/src/components/MkSystemWebhookEditor.vue
@@ -0,0 +1,217 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkModalWindow
+ :width="450"
+ :height="590"
+ :canClose="true"
+ :withOkButton="false"
+ :okButtonDisabled="false"
+ @click="onCancelClicked"
+ @close="onCancelClicked"
+ @closed="onCancelClicked"
+>
+ <template #header>
+ {{ mode === 'create' ? i18n.ts._webhookSettings.createWebhook : i18n.ts._webhookSettings.modifyWebhook }}
+ </template>
+ <MkSpacer :marginMin="20" :marginMax="28">
+ <MkLoading v-if="loading !== 0"/>
+ <div v-else :class="$style.root" class="_gaps_m">
+ <MkInput v-model="title">
+ <template #label>{{ i18n.ts._webhookSettings.name }}</template>
+ </MkInput>
+ <MkInput v-model="url">
+ <template #label>URL</template>
+ </MkInput>
+ <MkInput v-model="secret">
+ <template #label>{{ i18n.ts._webhookSettings.secret }}</template>
+ </MkInput>
+ <MkFolder :defaultOpen="true">
+ <template #label>{{ i18n.ts._webhookSettings.events }}</template>
+
+ <div class="_gaps_s">
+ <MkSwitch v-model="events.abuseReport" :disabled="disabledEvents.abuseReport">
+ <template #label>{{ i18n.ts._webhookSettings._systemEvents.abuseReport }}</template>
+ </MkSwitch>
+ <MkSwitch v-model="events.abuseReportResolved" :disabled="disabledEvents.abuseReportResolved">
+ <template #label>{{ i18n.ts._webhookSettings._systemEvents.abuseReportResolved }}</template>
+ </MkSwitch>
+ </div>
+ </MkFolder>
+
+ <MkSwitch v-model="isActive">
+ <template #label>{{ i18n.ts.enable }}</template>
+ </MkSwitch>
+
+ <div :class="$style.footer" class="_buttonsCenter">
+ <MkButton primary :disabled="disableSubmitButton" @click="onSubmitClicked">
+ <i class="ti ti-check"></i>
+ {{ i18n.ts.ok }}
+ </MkButton>
+ <MkButton @click="onCancelClicked"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
+ </div>
+ </div>
+ </MkSpacer>
+</MkModalWindow>
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, ref, toRefs } from 'vue';
+import FormSection from '@/components/form/section.vue';
+import MkInput from '@/components/MkInput.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import {
+ MkSystemWebhookEditorProps,
+ MkSystemWebhookResult,
+ SystemWebhookEventType,
+} from '@/components/MkSystemWebhookEditor.impl.js';
+import { i18n } from '@/i18n.js';
+import MkButton from '@/components/MkButton.vue';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import MkModalWindow from '@/components/MkModalWindow.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import * as os from '@/os.js';
+
+type EventType = {
+ abuseReport: boolean;
+ abuseReportResolved: boolean;
+}
+
+const emit = defineEmits<{
+ (ev: 'submitted', result: MkSystemWebhookResult): void;
+ (ev: 'closed'): void;
+}>();
+
+const props = defineProps<MkSystemWebhookEditorProps>();
+
+const { mode, id, requiredEvents } = toRefs(props);
+
+const loading = ref<number>(0);
+
+const title = ref<string>('');
+const url = ref<string>('');
+const secret = ref<string>('');
+const events = ref<EventType>({
+ abuseReport: true,
+ abuseReportResolved: true,
+});
+const isActive = ref<boolean>(true);
+
+const disabledEvents = ref<EventType>({
+ abuseReport: false,
+ abuseReportResolved: false,
+});
+
+const disableSubmitButton = computed(() => {
+ if (!title.value) {
+ return true;
+ }
+ if (!url.value) {
+ return true;
+ }
+ if (!secret.value) {
+ return true;
+ }
+
+ return false;
+});
+
+async function onSubmitClicked() {
+ await loadingScope(async () => {
+ const params = {
+ isActive: isActive.value,
+ name: title.value,
+ url: url.value,
+ secret: secret.value,
+ on: Object.keys(events.value).filter(ev => events.value[ev as keyof EventType]) as SystemWebhookEventType[],
+ };
+
+ try {
+ switch (mode.value) {
+ case 'create': {
+ const result = await misskeyApi('admin/system-webhook/create', params);
+ emit('submitted', result);
+ break;
+ }
+ case 'edit': {
+ // eslint-disable-next-line
+ const result = await misskeyApi('admin/system-webhook/update', { id: id.value!, ...params });
+ emit('submitted', result);
+ break;
+ }
+ }
+ // eslint-disable-next-line
+ } catch (ex: any) {
+ const msg = ex.message ?? i18n.ts.internalServerErrorDescription;
+ await os.alert({ type: 'error', title: i18n.ts.error, text: msg });
+ emit('closed');
+ }
+ });
+}
+
+function onCancelClicked() {
+ emit('closed');
+}
+
+async function loadingScope<T>(fn: () => Promise<T>): Promise<T> {
+ loading.value++;
+ try {
+ return await fn();
+ } finally {
+ loading.value--;
+ }
+}
+
+onMounted(async () => {
+ await loadingScope(async () => {
+ switch (mode.value) {
+ case 'edit': {
+ if (!id.value) {
+ throw new Error('id is required');
+ }
+
+ try {
+ const res = await misskeyApi('admin/system-webhook/show', { id: id.value });
+
+ title.value = res.name;
+ url.value = res.url;
+ secret.value = res.secret;
+ isActive.value = res.isActive;
+ for (const ev of Object.keys(events.value)) {
+ events.value[ev] = res.on.includes(ev as SystemWebhookEventType);
+ }
+ // eslint-disable-next-line
+ } catch (ex: any) {
+ const msg = ex.message ?? i18n.ts.internalServerErrorDescription;
+ await os.alert({ type: 'error', title: i18n.ts.error, text: msg });
+ emit('closed');
+ }
+ break;
+ }
+ }
+
+ for (const ev of requiredEvents.value ?? []) {
+ disabledEvents.value[ev] = true;
+ }
+ });
+});
+</script>
+
+<style module lang="scss">
+.root {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: stretch;
+}
+
+.footer {
+ display: flex;
+ justify-content: center;
+ align-items: flex-end;
+ margin-top: 20px;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue
new file mode 100644
index 0000000000..ffe9c620d6
--- /dev/null
+++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue
@@ -0,0 +1,307 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkModalWindow
+ ref="dialog"
+ :width="400"
+ :height="490"
+ :withOkButton="false"
+ :okButtonDisabled="false"
+ @close="onCancelClicked"
+ @closed="emit('closed')"
+>
+ <template #header>
+ {{ mode === 'create' ? i18n.ts._abuseReport._notificationRecipient.createRecipient : i18n.ts._abuseReport._notificationRecipient.modifyRecipient }}
+ </template>
+ <div v-if="loading === 0">
+ <MkSpacer :marginMin="20" :marginMax="28">
+ <div :class="$style.root" class="_gaps_m">
+ <MkInput v-model="title">
+ <template #label>{{ i18n.ts.title }}</template>
+ </MkInput>
+ <MkSelect v-model="method">
+ <template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template>
+ <option value="email">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option>
+ <option value="webhook">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option>
+ <template #caption>
+ {{ methodCaption }}
+ </template>
+ </MkSelect>
+ <div>
+ <MkSelect v-if="method === 'email'" v-model="userId">
+ <template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedUser }}</template>
+ <option v-for="user in moderators" :key="user.id" :value="user.id">
+ {{ user.name ? `${user.name}(${user.username})` : user.username }}
+ </option>
+ </MkSelect>
+ <div v-else-if="method === 'webhook'" :class="$style.systemWebhook">
+ <MkSelect v-model="systemWebhookId" style="flex: 1">
+ <template #label>{{ i18n.ts._abuseReport._notificationRecipient.notifiedWebhook }}</template>
+ <option v-for="webhook in systemWebhooks" :key="webhook.id ?? undefined" :value="webhook.id">
+ {{ webhook.name }}
+ </option>
+ </MkSelect>
+ <MkButton rounded @click="onEditSystemWebhookClicked">
+ <span v-if="systemWebhookId === null" class="ti ti-plus" style="line-height: normal"/>
+ <span v-else class="ti ti-settings" style="line-height: normal"/>
+ </MkButton>
+ </div>
+ </div>
+
+ <MkDivider/>
+
+ <MkSwitch v-model="isActive">
+ <template #label>{{ i18n.ts.enable }}</template>
+ </MkSwitch>
+ </div>
+ </MkSpacer>
+
+ <div :class="$style.footer" class="_buttonsCenter">
+ <MkButton primary :disabled="disableSubmitButton" @click="onSubmitClicked"><i class="ti ti-check"></i> {{ i18n.ts.ok }}</MkButton>
+ <MkButton @click="onCancelClicked"><i class="ti ti-x"></i> {{ i18n.ts.cancel }}</MkButton>
+ </div>
+ </div>
+ <div v-else>
+ <MkLoading/>
+ </div>
+</MkModalWindow>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref, toRefs } from 'vue';
+import { entities } from 'misskey-js';
+import MkButton from '@/components/MkButton.vue';
+import MkModalWindow from '@/components/MkModalWindow.vue';
+import { i18n } from '@/i18n.js';
+import MkInput from '@/components/MkInput.vue';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import MkSelect from '@/components/MkSelect.vue';
+import { MkSystemWebhookResult, showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js';
+import MkSwitch from '@/components/MkSwitch.vue';
+import MkDivider from '@/components/MkDivider.vue';
+import * as os from '@/os.js';
+
+type NotificationRecipientMethod = 'email' | 'webhook';
+
+const emit = defineEmits<{
+ (ev: 'submitted'): void;
+ (ev: 'closed'): void;
+}>();
+
+const props = defineProps<{
+ mode: 'create' | 'edit';
+ id?: string;
+}>();
+
+const { mode, id } = toRefs(props);
+
+const loading = ref<number>(0);
+
+const title = ref<string>('');
+const method = ref<NotificationRecipientMethod>('email');
+const userId = ref<string | null>(null);
+const systemWebhookId = ref<string | null>(null);
+const isActive = ref<boolean>(true);
+
+const moderators = ref<entities.User[]>([]);
+const systemWebhooks = ref<(entities.SystemWebhook | { id: null, name: string })[]>([]);
+
+const methodCaption = computed(() => {
+ switch (method.value) {
+ case 'email': {
+ return i18n.ts._abuseReport._notificationRecipient._recipientType._captions.mail;
+ }
+ case 'webhook': {
+ return i18n.ts._abuseReport._notificationRecipient._recipientType._captions.webhook;
+ }
+ default: {
+ return '';
+ }
+ }
+});
+
+const disableSubmitButton = computed(() => {
+ if (!title.value) {
+ return true;
+ }
+
+ switch (method.value) {
+ case 'email': {
+ return userId.value === null;
+ }
+ case 'webhook': {
+ return systemWebhookId.value === null;
+ }
+ default: {
+ return true;
+ }
+ }
+});
+
+async function onSubmitClicked() {
+ await loadingScope(async () => {
+ const _userId = (method.value === 'email') ? userId.value : null;
+ const _systemWebhookId = (method.value === 'webhook') ? systemWebhookId.value : null;
+ const params = {
+ isActive: isActive.value,
+ name: title.value,
+ method: method.value,
+ userId: _userId ?? undefined,
+ systemWebhookId: _systemWebhookId ?? undefined,
+ };
+
+ try {
+ switch (mode.value) {
+ case 'create': {
+ await misskeyApi('admin/abuse-report/notification-recipient/create', params);
+ break;
+ }
+ case 'edit': {
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ await misskeyApi('admin/abuse-report/notification-recipient/update', { id: id.value!, ...params });
+ break;
+ }
+ }
+
+ emit('submitted');
+ // eslint-disable-next-line
+ } catch (ex: any) {
+ const msg = ex.message ?? i18n.ts.internalServerErrorDescription;
+ await os.alert({ type: 'error', title: i18n.ts.error, text: msg });
+ emit('closed');
+ }
+ });
+}
+
+function onCancelClicked() {
+ emit('closed');
+}
+
+async function onEditSystemWebhookClicked() {
+ let result: MkSystemWebhookResult | null;
+ if (systemWebhookId.value === null) {
+ result = await showSystemWebhookEditorDialog({
+ mode: 'create',
+ });
+ } else {
+ result = await showSystemWebhookEditorDialog({
+ mode: 'edit',
+ id: systemWebhookId.value,
+ });
+ }
+ if (!result) {
+ return;
+ }
+
+ await fetchSystemWebhooks();
+ systemWebhookId.value = result.id ?? null;
+}
+
+async function fetchSystemWebhooks() {
+ await loadingScope(async () => {
+ systemWebhooks.value = [
+ { id: null, name: i18n.ts.createNew },
+ ...await misskeyApi('admin/system-webhook/list', { }),
+ ];
+ });
+}
+
+async function fetchModerators() {
+ await loadingScope(async () => {
+ const users = Array.of<entities.User>();
+ for (; ;) {
+ const res = await misskeyApi('admin/show-users', {
+ limit: 100,
+ state: 'adminOrModerator',
+ origin: 'local',
+ offset: users.length,
+ });
+
+ if (res.length === 0) {
+ break;
+ }
+
+ users.push(...res);
+ }
+
+ moderators.value = users;
+ });
+}
+
+async function loadingScope<T>(fn: () => Promise<T>): Promise<T> {
+ loading.value++;
+ try {
+ return await fn();
+ } finally {
+ loading.value--;
+ }
+}
+
+onMounted(async () => {
+ await loadingScope(async () => {
+ await fetchModerators();
+ await fetchSystemWebhooks();
+
+ if (mode.value === 'edit') {
+ if (!id.value) {
+ throw new Error('id is required');
+ }
+
+ try {
+ const res = await misskeyApi('admin/abuse-report/notification-recipient/show', { id: id.value });
+
+ title.value = res.name;
+ method.value = res.method;
+ userId.value = res.userId ?? null;
+ systemWebhookId.value = res.systemWebhookId ?? null;
+ isActive.value = res.isActive;
+ // eslint-disable-next-line
+ } catch (ex: any) {
+ const msg = ex.message ?? i18n.ts.internalServerErrorDescription;
+ await os.alert({ type: 'error', title: i18n.ts.error, text: msg });
+ emit('closed');
+ }
+ } else {
+ userId.value = moderators.value[0]?.id ?? null;
+ systemWebhookId.value = systemWebhooks.value[0]?.id ?? null;
+ }
+ });
+});
+
+</script>
+
+<style lang="scss" module>
+.root {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: stretch;
+}
+
+.footer {
+ display: flex;
+ justify-content: center;
+ align-items: flex-end;
+ margin-top: 20px;
+}
+
+.systemWebhook {
+ display: flex;
+ flex-direction: row;
+ justify-content: stretch;
+ align-items: flex-end;
+ gap: 8px;
+
+ button {
+ width: 2.5em;
+ height: 2.5em;
+ min-width: 2.5em;
+ min-height: 2.5em;
+ box-sizing: border-box;
+ padding: 6px;
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue
new file mode 100644
index 0000000000..0b86808faf
--- /dev/null
+++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue
@@ -0,0 +1,114 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.root" class="_panel _gaps_s">
+ <div :class="$style.rightDivider" style="width: 80px;"><span :class="`ti ${methodIcon}`"/> {{ methodName }}</div>
+ <div :class="$style.rightDivider" style="flex: 0.5">{{ entity.name }}</div>
+ <div :class="$style.rightDivider" style="flex: 1">
+ <div v-if="method === 'email' && user">
+ {{
+ `${i18n.ts._abuseReport._notificationRecipient.notifiedUser}: ` + ((user.name) ? `${user.name}(${user.username})` : user.username)
+ }}
+ </div>
+ <div v-if="method === 'webhook' && systemWebhook">
+ {{ `${i18n.ts._abuseReport._notificationRecipient.notifiedWebhook}: ` + systemWebhook.name }}
+ </div>
+ </div>
+ <div :class="$style.recipientButtons" style="margin-left: auto">
+ <button :class="$style.recipientButton" @click="onEditButtonClicked()">
+ <span class="ti ti-settings"/>
+ </button>
+ <button :class="$style.recipientButton" @click="onDeleteButtonClicked()">
+ <span class="ti ti-trash"/>
+ </button>
+ </div>
+</div>
+</template>
+
+<script setup lang="ts">
+import { entities } from 'misskey-js';
+import { computed, toRefs } from 'vue';
+import { i18n } from '@/i18n.js';
+
+const emit = defineEmits<{
+ (ev: 'edit', id: entities.AbuseReportNotificationRecipient['id']): void;
+ (ev: 'delete', id: entities.AbuseReportNotificationRecipient['id']): void;
+}>();
+
+const props = defineProps<{
+ entity: entities.AbuseReportNotificationRecipient;
+}>();
+
+const { entity } = toRefs(props);
+
+const method = computed(() => entity.value.method);
+const user = computed(() => entity.value.user);
+const systemWebhook = computed(() => entity.value.systemWebhook);
+const methodIcon = computed(() => {
+ switch (entity.value.method) {
+ case 'email':
+ return 'ti-mail';
+ case 'webhook':
+ return 'ti-webhook';
+ default:
+ return 'ti-help';
+ }
+});
+const methodName = computed(() => {
+ switch (entity.value.method) {
+ case 'email':
+ return i18n.ts._abuseReport._notificationRecipient._recipientType.mail;
+ case 'webhook':
+ return i18n.ts._abuseReport._notificationRecipient._recipientType.webhook;
+ default:
+ return '不明';
+ }
+});
+
+function onEditButtonClicked() {
+ emit('edit', entity.value.id);
+}
+
+function onDeleteButtonClicked() {
+ emit('delete', entity.value.id);
+}
+</script>
+
+<style module lang="scss">
+.root {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ padding: 4px 8px;
+}
+
+.rightDivider {
+ border-right: 0.5px solid var(--divider);
+}
+
+.recipientButtons {
+ display: flex;
+ flex-direction: row;
+ justify-content: center;
+ align-items: center;
+ margin-right: -4;
+}
+
+.recipientButton {
+ background-color: transparent;
+ border: none;
+ border-radius: 9999px;
+ box-sizing: border-box;
+ margin-top: -2px;
+ margin-bottom: -2px;
+ padding: 8px;
+
+ &:hover {
+ background-color: var(--buttonBg);
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue
new file mode 100644
index 0000000000..a52f8eb7af
--- /dev/null
+++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue
@@ -0,0 +1,176 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkStickyContainer>
+ <template #header>
+ <XHeader :actions="headerActions" :tabs="headerTabs"/>
+ </template>
+
+ <MkSpacer :contentMax="900">
+ <div :class="$style.root" class="_gaps_m">
+ <div :class="$style.addButton">
+ <MkButton primary @click="onAddButtonClicked">
+ <span class="ti ti-plus"/> {{ i18n.ts._abuseReport._notificationRecipient.createRecipient }}
+ </MkButton>
+ </div>
+ <div :class="$style.subMenus" class="_gaps_s">
+ <MkSelect v-model="filterMethod" style="flex: 1">
+ <template #label>{{ i18n.ts._abuseReport._notificationRecipient.recipientType }}</template>
+ <option :value="null">-</option>
+ <option :value="'email'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.mail }}</option>
+ <option :value="'webhook'">{{ i18n.ts._abuseReport._notificationRecipient._recipientType.webhook }}</option>
+ </MkSelect>
+ <MkInput v-model="filterText" type="search" style="flex: 1">
+ <template #label>{{ i18n.ts._abuseReport._notificationRecipient.keywords }}</template>
+ </MkInput>
+ </div>
+
+ <MkDivider/>
+
+ <div :class="$style.recipients" class="_gaps_s">
+ <XRecipient
+ v-for="r in filteredRecipients"
+ :key="r.id"
+ :entity="r"
+ @edit="onEditButtonClicked"
+ @delete="onDeleteButtonClicked"
+ />
+ </div>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script setup lang="ts">
+import { entities } from 'misskey-js';
+import { computed, defineAsyncComponent, onMounted, ref } from 'vue';
+import XRecipient from './notification-recipient.item.vue';
+import XHeader from '@/pages/admin/_header_.vue';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import MkInput from '@/components/MkInput.vue';
+import MkSelect from '@/components/MkSelect.vue';
+import MkButton from '@/components/MkButton.vue';
+import * as os from '@/os.js';
+import MkDivider from '@/components/MkDivider.vue';
+import { i18n } from '@/i18n.js';
+
+const recipients = ref<entities.AbuseReportNotificationRecipient[]>([]);
+
+const filterMethod = ref<string | null>(null);
+const filterText = ref<string>('');
+
+const filteredRecipients = computed(() => {
+ const method = filterMethod.value;
+ const text = filterText.value.trim().length === 0 ? null : filterText.value;
+
+ return recipients.value.filter(it => {
+ if (method ?? text) {
+ if (text) {
+ const keywords = [it.name, it.systemWebhook?.name, it.user?.name, it.user?.username];
+ if (keywords.filter(k => k?.includes(text)).length !== 0) {
+ return true;
+ }
+ }
+
+ if (method) {
+ return it.method.includes(method);
+ }
+
+ return false;
+ }
+
+ return true;
+ });
+});
+const headerActions = computed(() => []);
+const headerTabs = computed(() => []);
+
+async function onAddButtonClicked() {
+ await showEditor('create');
+}
+
+async function onEditButtonClicked(id: string) {
+ await showEditor('edit', id);
+}
+
+async function onDeleteButtonClicked(id: string) {
+ const res = await os.confirm({
+ type: 'warning',
+ title: i18n.ts._abuseReport._notificationRecipient.deleteConfirm,
+ });
+ if (!res.canceled) {
+ await misskeyApi('admin/abuse-report/notification-recipient/delete', { id: id });
+ await fetchRecipients();
+ }
+}
+
+async function showEditor(mode: 'create' | 'edit', id?: string) {
+ const { dispose, needLoad } = await new Promise<{ dispose: () => void, needLoad: boolean }>(async resolve => {
+ const res = await os.popup(
+ defineAsyncComponent(() => import('./notification-recipient.editor.vue')),
+ {
+ mode,
+ id,
+ },
+ {
+ submitted: async () => {
+ resolve({ dispose: res.dispose, needLoad: true });
+ },
+ closed: () => {
+ resolve({ dispose: res.dispose, needLoad: false });
+ },
+ },
+ );
+ });
+
+ dispose();
+
+ if (needLoad) {
+ await fetchRecipients();
+ }
+}
+
+async function fetchRecipients() {
+ const result = await misskeyApi('admin/abuse-report/notification-recipient/list', {
+ method: ['email', 'webhook'],
+ });
+
+ recipients.value = result.sort((a, b) => (a.method + a.id).localeCompare(b.method + b.id));
+}
+
+onMounted(async () => {
+ await fetchRecipients();
+});
+</script>
+
+<style module lang="scss">
+.root {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: stretch;
+}
+
+.addButton {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+}
+
+.subMenus {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: flex-end;
+}
+
+.recipients {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: stretch;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/abuses.vue b/packages/frontend/src/pages/admin/abuses.vue
index d2f4a4b531..9a9fa472a5 100644
--- a/packages/frontend/src/pages/admin/abuses.vue
+++ b/packages/frontend/src/pages/admin/abuses.vue
@@ -7,30 +7,33 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="900">
- <div>
- <div class="reports">
- <div class="">
- <div class="inputs" style="display: flex;">
- <MkSelect v-model="state" style="margin: 0; flex: 1;">
- <template #label>{{ i18n.ts.state }}</template>
- <option value="all">{{ i18n.ts.all }}</option>
- <option value="unresolved">{{ i18n.ts.unresolved }}</option>
- <option value="resolved">{{ i18n.ts.resolved }}</option>
- </MkSelect>
- <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
- <template #label>{{ i18n.ts.reporteeOrigin }}</template>
- <option value="combined">{{ i18n.ts.all }}</option>
- <option value="local">{{ i18n.ts.local }}</option>
- <option value="remote">{{ i18n.ts.remote }}</option>
- </MkSelect>
- <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
- <template #label>{{ i18n.ts.reporterOrigin }}</template>
- <option value="combined">{{ i18n.ts.all }}</option>
- <option value="local">{{ i18n.ts.local }}</option>
- <option value="remote">{{ i18n.ts.remote }}</option>
- </MkSelect>
- </div>
- <!-- TODO
+ <div :class="$style.root" class="_gaps">
+ <div :class="$style.subMenus" class="_gaps">
+ <MkButton link to="/admin/abuse-report-notification-recipient" primary>{{ "通知設定" }}</MkButton>
+ </div>
+
+ <div :class="$style.inputs" class="_gaps">
+ <MkSelect v-model="state" style="margin: 0; flex: 1;">
+ <template #label>{{ i18n.ts.state }}</template>
+ <option value="all">{{ i18n.ts.all }}</option>
+ <option value="unresolved">{{ i18n.ts.unresolved }}</option>
+ <option value="resolved">{{ i18n.ts.resolved }}</option>
+ </MkSelect>
+ <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;">
+ <template #label>{{ i18n.ts.reporteeOrigin }}</template>
+ <option value="combined">{{ i18n.ts.all }}</option>
+ <option value="local">{{ i18n.ts.local }}</option>
+ <option value="remote">{{ i18n.ts.remote }}</option>
+ </MkSelect>
+ <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;">
+ <template #label>{{ i18n.ts.reporterOrigin }}</template>
+ <option value="combined">{{ i18n.ts.all }}</option>
+ <option value="local">{{ i18n.ts.local }}</option>
+ <option value="remote">{{ i18n.ts.remote }}</option>
+ </MkSelect>
+ </div>
+
+ <!-- TODO
<div class="inputs" style="display: flex; padding-top: 1.2em;">
<MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" :spellcheck="false">
<span>{{ i18n.ts.username }}</span>
@@ -41,11 +44,9 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
-->
- <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
- <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
- </MkPagination>
- </div>
- </div>
+ <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);">
+ <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/>
+ </MkPagination>
</div>
</MkSpacer>
</MkStickyContainer>
@@ -60,6 +61,7 @@ import MkPagination from '@/components/MkPagination.vue';
import XAbuseReport from '@/components/MkAbuseReport.vue';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
+import MkButton from '@/components/MkButton.vue';
const reports = shallowRef<InstanceType<typeof MkPagination>>();
@@ -80,7 +82,7 @@ const pagination = {
};
function resolved(reportId) {
- reports.value.removeItem(reportId);
+ reports.value?.removeItem(reportId);
}
const headerActions = computed(() => []);
@@ -92,3 +94,26 @@ definePageMetadata(() => ({
icon: 'ti ti-exclamation-circle',
}));
</script>
+
+<style module lang="scss">
+.root {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: stretch;
+}
+
+.subMenus {
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-end;
+ align-items: center;
+}
+
+.inputs {
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue
index 794feae202..292f10da1a 100644
--- a/packages/frontend/src/pages/admin/index.vue
+++ b/packages/frontend/src/pages/admin/index.vue
@@ -215,6 +215,11 @@ const menuDef = computed(() => [{
to: '/admin/external-services',
active: currentPage.value?.route.name === 'external-services',
}, {
+ icon: 'ti ti-webhook',
+ text: 'Webhook',
+ to: '/admin/system-webhook',
+ active: currentPage.value?.route.name === 'system-webhook',
+ }, {
icon: 'ti ti-adjustments',
text: i18n.ts.other,
to: '/admin/other-settings',
diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue
index e33c882721..91f1c7c5e6 100644
--- a/packages/frontend/src/pages/admin/modlog.ModLog.vue
+++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue
@@ -8,9 +8,35 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>
<b
:class="{
- [$style.logGreen]: ['createRole', 'addCustomEmoji', 'createGlobalAnnouncement', 'createUserAnnouncement', 'createAd', 'createInvitation', 'createAvatarDecoration'].includes(log.type),
- [$style.logYellow]: ['markSensitiveDriveFile', 'resetPassword'].includes(log.type),
- [$style.logRed]: ['suspend', 'deleteRole', 'suspendRemoteInstance', 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'deleteCustomEmoji', 'deleteNote', 'deleteDriveFile', 'deleteAd', 'deleteAvatarDecoration'].includes(log.type)
+ [$style.logGreen]: [
+ 'createRole',
+ 'addCustomEmoji',
+ 'createGlobalAnnouncement',
+ 'createUserAnnouncement',
+ 'createAd',
+ 'createInvitation',
+ 'createAvatarDecoration',
+ 'createSystemWebhook',
+ 'createAbuseReportNotificationRecipient',
+ ].includes(log.type),
+ [$style.logYellow]: [
+ 'markSensitiveDriveFile',
+ 'resetPassword'
+ ].includes(log.type),
+ [$style.logRed]: [
+ 'suspend',
+ 'deleteRole',
+ 'suspendRemoteInstance',
+ 'deleteGlobalAnnouncement',
+ 'deleteUserAnnouncement',
+ 'deleteCustomEmoji',
+ 'deleteNote',
+ 'deleteDriveFile',
+ 'deleteAd',
+ 'deleteAvatarDecoration',
+ 'deleteSystemWebhook',
+ 'deleteAbuseReportNotificationRecipient',
+ ].includes(log.type)
}"
>{{ i18n.ts._moderationLogTypes[log.type] }}</b>
<span v-if="log.type === 'updateUserNote'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span>
@@ -40,6 +66,12 @@ SPDX-License-Identifier: AGPL-3.0-only
<span v-else-if="log.type === 'createAvatarDecoration'">: {{ log.info.avatarDecoration.name }}</span>
<span v-else-if="log.type === 'updateAvatarDecoration'">: {{ log.info.before.name }}</span>
<span v-else-if="log.type === 'deleteAvatarDecoration'">: {{ log.info.avatarDecoration.name }}</span>
+ <span v-else-if="log.type === 'createSystemWebhook'">: {{ log.info.webhook.name }}</span>
+ <span v-else-if="log.type === 'updateSystemWebhook'">: {{ log.info.before.name }}</span>
+ <span v-else-if="log.type === 'deleteSystemWebhook'">: {{ log.info.webhook.name }}</span>
+ <span v-else-if="log.type === 'createAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span>
+ <span v-else-if="log.type === 'updateAbuseReportNotificationRecipient'">: {{ log.info.before.name }}</span>
+ <span v-else-if="log.type === 'deleteAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span>
</template>
<template #icon>
<MkAvatar :user="log.user" :class="$style.avatar"/>
@@ -116,6 +148,16 @@ SPDX-License-Identifier: AGPL-3.0-only
<CodeDiff :context="5" :hideHeader="true" :oldString="log.info.before ?? ''" :newString="log.info.after ?? ''" maxHeight="300px"/>
</div>
</template>
+ <template v-else-if="log.type === 'updateSystemWebhook'">
+ <div :class="$style.diff">
+ <CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/>
+ </div>
+ </template>
+ <template v-else-if="log.type === 'updateAbuseReportNotificationRecipient'">
+ <div :class="$style.diff">
+ <CodeDiff :context="5" :hideHeader="true" :oldString="JSON5.stringify(log.info.before, null, '\t')" :newString="JSON5.stringify(log.info.after, null, '\t')" language="javascript" maxHeight="300px"/>
+ </div>
+ </template>
<details>
<summary>raw</summary>
diff --git a/packages/frontend/src/pages/admin/system-webhook.item.vue b/packages/frontend/src/pages/admin/system-webhook.item.vue
new file mode 100644
index 0000000000..0c07122af3
--- /dev/null
+++ b/packages/frontend/src/pages/admin/system-webhook.item.vue
@@ -0,0 +1,117 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<div :class="$style.main">
+ <span :class="$style.icon">
+ <i v-if="!entity.isActive" class="ti ti-player-pause"/>
+ <i v-else-if="entity.latestStatus === null" class="ti ti-circle"/>
+ <i
+ v-else-if="[200, 201, 204].includes(entity.latestStatus)"
+ class="ti ti-check"
+ :style="{ color: 'var(--success)' }"
+ />
+ <i v-else class="ti ti-alert-triangle" :style="{ color: 'var(--error)' }"/>
+ </span>
+ <span :class="$style.text">{{ entity.name || entity.url }}</span>
+ <span :class="$style.suffix">
+ <MkTime v-if="entity.latestSentAt" :time="entity.latestSentAt" style="margin-right: 8px"/>
+ <button :class="$style.suffixButton" @click="onEditClick">
+ <i class="ti ti-settings"></i>
+ </button>
+ <button :class="$style.suffixButton" @click="onDeleteClick">
+ <i class="ti ti-trash"></i>
+ </button>
+ </span>
+</div>
+</template>
+
+<script lang="ts" setup>
+import { entities } from 'misskey-js';
+import { toRefs } from 'vue';
+
+const emit = defineEmits<{
+ (ev: 'edit', value: entities.SystemWebhook): void;
+ (ev: 'delete', value: entities.SystemWebhook): void;
+}>();
+
+const props = defineProps<{
+ entity: entities.SystemWebhook;
+}>();
+
+const { entity } = toRefs(props);
+
+function onEditClick() {
+ emit('edit', entity.value);
+}
+
+function onDeleteClick() {
+ emit('delete', entity.value);
+}
+
+</script>
+
+<style module lang="scss">
+.main {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ box-sizing: border-box;
+ padding: 10px 14px;
+ background: var(--buttonBg);
+ border: none;
+ border-radius: 6px;
+ font-size: 0.9em;
+
+ &:hover {
+ text-decoration: none;
+ background: var(--buttonHoverBg);
+ }
+
+ &.active {
+ color: var(--accent);
+ background: var(--buttonHoverBg);
+ }
+}
+
+.icon {
+ margin-right: 0.75em;
+ flex-shrink: 0;
+ text-align: center;
+ color: var(--fgTransparentWeak);
+}
+
+.text {
+ flex-shrink: 1;
+ white-space: normal;
+ padding-right: 12px;
+ text-align: center;
+}
+
+.suffix {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ justify-content: center;
+ gaps: 4px;
+ margin-left: auto;
+ margin-right: -8px;
+ opacity: 0.7;
+ white-space: nowrap;
+}
+
+.suffixButton {
+ background: transparent;
+ border: none;
+ border-radius: 9999px;
+ margin-top: -8px;
+ margin-bottom: -8px;
+ padding: 8px;
+
+ &:hover {
+ background: var(--buttonBg);
+ }
+}
+</style>
diff --git a/packages/frontend/src/pages/admin/system-webhook.vue b/packages/frontend/src/pages/admin/system-webhook.vue
new file mode 100644
index 0000000000..7a40eec944
--- /dev/null
+++ b/packages/frontend/src/pages/admin/system-webhook.vue
@@ -0,0 +1,96 @@
+<!--
+SPDX-FileCopyrightText: syuilo and misskey-project
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkStickyContainer>
+ <template #header>
+ <XHeader :actions="headerActions" :tabs="headerTabs"/>
+ </template>
+
+ <MkSpacer :contentMax="900">
+ <div class="_gaps_m">
+ <MkButton :class="$style.linkButton" full @click="onCreateWebhookClicked">
+ {{ i18n.ts._webhookSettings.createWebhook }}
+ </MkButton>
+
+ <FormSection>
+ <div class="_gaps">
+ <XItem v-for="item in webhooks" :key="item.id" :entity="item" @edit="onEditButtonClicked" @delete="onDeleteButtonClicked"/>
+ </div>
+ </FormSection>
+ </div>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, onMounted, ref } from 'vue';
+import { entities } from 'misskey-js';
+import XItem from './system-webhook.item.vue';
+import FormSection from '@/components/form/section.vue';
+import { definePageMetadata } from '@/scripts/page-metadata.js';
+import { i18n } from '@/i18n.js';
+import XHeader from '@/pages/admin/_header_.vue';
+import MkButton from '@/components/MkButton.vue';
+import { misskeyApi } from '@/scripts/misskey-api.js';
+import { showSystemWebhookEditorDialog } from '@/components/MkSystemWebhookEditor.impl.js';
+import * as os from '@/os.js';
+
+const webhooks = ref<entities.SystemWebhook[]>([]);
+
+const headerActions = computed(() => []);
+const headerTabs = computed(() => []);
+
+async function onCreateWebhookClicked() {
+ await showSystemWebhookEditorDialog({
+ mode: 'create',
+ });
+
+ await fetchWebhooks();
+}
+
+async function onEditButtonClicked(webhook: entities.SystemWebhook) {
+ await showSystemWebhookEditorDialog({
+ mode: 'edit',
+ id: webhook.id,
+ });
+
+ await fetchWebhooks();
+}
+
+async function onDeleteButtonClicked(webhook: entities.SystemWebhook) {
+ const result = await os.confirm({
+ type: 'warning',
+ title: i18n.ts._webhookSettings.deleteConfirm,
+ });
+ if (!result.canceled) {
+ await misskeyApi('admin/system-webhook/delete', {
+ id: webhook.id,
+ });
+ await fetchWebhooks();
+ }
+}
+
+async function fetchWebhooks() {
+ const result = await misskeyApi('admin/system-webhook/list', {});
+ webhooks.value = result.sort((a, b) => a.id.localeCompare(b.id));
+}
+
+onMounted(async () => {
+ await fetchWebhooks();
+});
+
+definePageMetadata(() => ({
+ title: 'SystemWebhook',
+ icon: 'ti ti-webhook',
+}));
+</script>
+
+<style module lang="scss">
+.linkButton {
+ text-align: left;
+ padding: 10px 18px;
+}
+</style>
diff --git a/packages/frontend/src/router/definition.ts b/packages/frontend/src/router/definition.ts
index c12ae0fa57..8a443f627b 100644
--- a/packages/frontend/src/router/definition.ts
+++ b/packages/frontend/src/router/definition.ts
@@ -472,6 +472,14 @@ const routes: RouteDef[] = [{
name: 'invites',
component: page(() => import('@/pages/admin/invites.vue')),
}, {
+ path: '/abuse-report-notification-recipient',
+ name: 'abuse-report-notification-recipient',
+ component: page(() => import('@/pages/admin/abuse-report/notification-recipient.vue')),
+ }, {
+ path: '/system-webhook',
+ name: 'system-webhook',
+ component: page(() => import('@/pages/admin/system-webhook.vue')),
+ }, {
path: '/',
component: page(() => import('@/pages/_empty_.vue')),
}],