diff options
| author | おさむのひと <46447427+samunohito@users.noreply.github.com> | 2024-06-08 15:34:19 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-06-08 15:34:19 +0900 |
| commit | 61fae45390283aee7ac582aa5303aae863de0f7a (patch) | |
| tree | 17182172ef9f932182fc55f2aabd7243d2be66b2 /packages/frontend/src | |
| parent | 配信停止したインスタンス一覧が見れなくなる問題を修... (diff) | |
| download | misskey-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')
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')), }], |