diff options
| author | dakkar <dakkar@thenautilus.net> | 2024-08-02 12:25:58 +0100 |
|---|---|---|
| committer | dakkar <dakkar@thenautilus.net> | 2024-08-02 12:25:58 +0100 |
| commit | cfa9b852df9e0293865b3acbd67d59265962e552 (patch) | |
| tree | a408ad670956a45c4e162e4ecc97a3624e2b0f20 /packages/frontend/src/pages/admin | |
| parent | merge: rate limit all password checks - fixes #540 (!568) (diff) | |
| parent | Merge pull request #14233 from misskey-dev/develop (diff) | |
| download | sharkey-cfa9b852df9e0293865b3acbd67d59265962e552.tar.gz sharkey-cfa9b852df9e0293865b3acbd67d59265962e552.tar.bz2 sharkey-cfa9b852df9e0293865b3acbd67d59265962e552.zip | |
Merge remote-tracking branch 'misskey/master' into feature/misskey-2024.07
Diffstat (limited to 'packages/frontend/src/pages/admin')
15 files changed, 1087 insertions, 108 deletions
diff --git a/packages/frontend/src/pages/admin/_header_.vue b/packages/frontend/src/pages/admin/_header_.vue index 5c9c32c964..61dc4f8549 100644 --- a/packages/frontend/src/pages/admin/_header_.vue +++ b/packages/frontend/src/pages/admin/_header_.vue @@ -24,8 +24,8 @@ SPDX-License-Identifier: AGPL-3.0-only <div class="buttons right"> <template v-if="actions"> <template v-for="action in actions"> - <MkButton v-if="action.asFullButton" class="fullButton" primary @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton> - <button v-else v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> + <MkButton v-if="action.asFullButton" class="fullButton" primary :disabled="action.disabled" @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton> + <button v-else v-tooltip.noDelay="action.text" class="_button button" :class="{ highlighted: action.highlighted }" :disabled="action.disabled" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> </template> </template> </div> @@ -56,6 +56,7 @@ const props = defineProps<{ text: string; icon: string; asFullButton?: boolean; + disabled?: boolean; handler: (ev: MouseEvent) => void; }[]; thin?: boolean; 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..827e22e8ae --- /dev/null +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue @@ -0,0 +1,321 @@ +<!-- +SPDX-FileCopyrightText: syuilo and misskey-project +SPDX-License-Identifier: AGPL-3.0-only +--> + +<template> +<MkModalWindow + ref="dialogEl" + :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" style="display: flex; flex-direction: column; min-height: 100%;"> + <MkSpacer :marginMin="20" :marginMax="28" style="flex-grow: 1;"> + <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 :class="$style.systemWebhookEditButton" @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 rounded :disabled="disableSubmitButton" @click="onSubmitClicked"><i class="ti ti-check"></i> {{ i18n.ts.ok }}</MkButton> + <MkButton rounded @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, shallowRef, 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: 'canceled'): void; + (ev: 'closed'): void; +}>(); + +const props = defineProps<{ + mode: 'create' | 'edit'; + id?: string; +}>(); + +const { mode, id } = toRefs(props); + +const dialogEl = shallowRef<InstanceType<typeof MkModalWindow>>(); + +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; + } + } + + dialogEl.value?.close(); + emit('submitted'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (ex: any) { + const msg = ex.message ?? i18n.ts.internalServerErrorDescription; + await os.alert({ type: 'error', title: i18n.ts.error, text: msg }); + dialogEl.value?.close(); + emit('canceled'); + } + }); +} + +function onCancelClicked() { + dialogEl.value?.close(); + emit('canceled'); +} + +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 }); + dialogEl.value?.close(); + emit('canceled'); + } + } 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 { + position: sticky; + z-index: 10000; + bottom: 0; + left: 0; + padding: 12px; + border-top: solid 0.5px var(--divider); + background: var(--acrylicBg); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); +} + +.systemWebhook { + display: flex; + flex-direction: row; + justify-content: stretch; + align-items: flex-end; + gap: 8px; +} + +.systemWebhookEditButton { + min-width: 0; + min-height: 0; + width: 34px; + height: 34px; + flex-shrink: 0; + box-sizing: border-box; + margin: 1px 0; + 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..f5249261be --- /dev/null +++ b/packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue @@ -0,0 +1,177 @@ +<!-- +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 { needLoad } = await new Promise<{ needLoad: boolean }>(async resolve => { + const { dispose } = os.popup( + defineAsyncComponent(() => import('./notification-recipient.editor.vue')), + { + mode, + id, + }, + { + submitted: () => { + resolve({ needLoad: true }); + }, + canceled: () => { + resolve({ needLoad: false }); + }, + closed: () => { + 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 39267b1345..fdc014a46e 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" :displayLimit="50" 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" :displayLimit="50" 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/announcements.vue b/packages/frontend/src/pages/admin/announcements.vue index e7fb62ec1d..b9e09c8d03 100644 --- a/packages/frontend/src/pages/admin/announcements.vue +++ b/packages/frontend/src/pages/admin/announcements.vue @@ -11,70 +11,83 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInfo>{{ i18n.ts._announcement.shouldNotBeUsedToPresentPermanentInfo }}</MkInfo> <MkInfo v-if="announcements.length > 5" warn>{{ i18n.ts._announcement.tooManyActiveAnnouncementDescription }}</MkInfo> - <MkFolder v-for="announcement in announcements" :key="announcement.id ?? announcement._id" :defaultOpen="announcement.id == null"> - <template #label>{{ announcement.title }}</template> - <template #icon> - <i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> - <i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i> - <i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> - <i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> - </template> - <template #caption>{{ announcement.text }}</template> + <MkSelect v-model="announcementsStatus"> + <template #label>{{ i18n.ts.filter }}</template> + <option value="active">{{ i18n.ts.active }}</option> + <option value="archived">{{ i18n.ts.archived }}</option> + </MkSelect> - <div class="_gaps_m"> - <MkInput v-model="announcement.title"> - <template #label>{{ i18n.ts.title }}</template> - </MkInput> - <MkTextarea v-model="announcement.text" mfmAutocomplete :mfmPreview="true"> - <template #label>{{ i18n.ts.text }}</template> - </MkTextarea> - <MkInput v-model="announcement.imageUrl" type="url"> - <template #label>{{ i18n.ts.imageUrl }}</template> - </MkInput> - <MkRadios v-model="announcement.icon"> - <template #label>{{ i18n.ts.icon }}</template> - <option value="info"><i class="ti ti-info-circle"></i></option> - <option value="warning"><i class="ti ti-alert-triangle" style="color: var(--warn);"></i></option> - <option value="error"><i class="ti ti-circle-x" style="color: var(--error);"></i></option> - <option value="success"><i class="ti ti-check" style="color: var(--success);"></i></option> - </MkRadios> - <MkRadios v-model="announcement.display"> - <template #label>{{ i18n.ts.display }}</template> - <option value="normal">{{ i18n.ts.normal }}</option> - <option value="banner">{{ i18n.ts.banner }}</option> - <option value="dialog">{{ i18n.ts.dialog }}</option> - </MkRadios> - <MkInfo v-if="announcement.display === 'dialog'" warn>{{ i18n.ts._announcement.dialogAnnouncementUxWarn }}</MkInfo> - <MkSwitch v-model="announcement.forExistingUsers" :helpText="i18n.ts._announcement.forExistingUsersDescription"> - {{ i18n.ts._announcement.forExistingUsers }} - </MkSwitch> - <MkSwitch v-model="announcement.silence" :helpText="i18n.ts._announcement.silenceDescription"> - {{ i18n.ts._announcement.silence }} - </MkSwitch> - <MkSwitch v-model="announcement.needConfirmationToRead" :helpText="i18n.ts._announcement.needConfirmationToReadDescription"> - {{ i18n.ts._announcement.needConfirmationToRead }} - </MkSwitch> - <p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p> - <div class="buttons _buttons"> - <MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> - <MkButton v-if="announcement.id != null" class="button" inline @click="archive(announcement)"><i class="ti ti-check"></i> {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }})</MkButton> - <MkButton v-if="announcement.id != null" class="button" inline danger @click="del(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + <MkLoading v-if="loading"/> + + <template v-else> + <MkFolder v-for="announcement in announcements" :key="announcement.id ?? announcement._id" :defaultOpen="announcement.id == null"> + <template #label>{{ announcement.title }}</template> + <template #icon> + <i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i> + <i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i> + <i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i> + <i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i> + </template> + <template #caption>{{ announcement.text }}</template> + + <div class="_gaps_m"> + <MkInput v-model="announcement.title"> + <template #label>{{ i18n.ts.title }}</template> + </MkInput> + <MkTextarea v-model="announcement.text" mfmAutocomplete :mfmPreview="true"> + <template #label>{{ i18n.ts.text }}</template> + </MkTextarea> + <MkInput v-model="announcement.imageUrl" type="url"> + <template #label>{{ i18n.ts.imageUrl }}</template> + </MkInput> + <MkRadios v-model="announcement.icon"> + <template #label>{{ i18n.ts.icon }}</template> + <option value="info"><i class="ti ti-info-circle"></i></option> + <option value="warning"><i class="ti ti-alert-triangle" style="color: var(--warn);"></i></option> + <option value="error"><i class="ti ti-circle-x" style="color: var(--error);"></i></option> + <option value="success"><i class="ti ti-check" style="color: var(--success);"></i></option> + </MkRadios> + <MkRadios v-model="announcement.display"> + <template #label>{{ i18n.ts.display }}</template> + <option value="normal">{{ i18n.ts.normal }}</option> + <option value="banner">{{ i18n.ts.banner }}</option> + <option value="dialog">{{ i18n.ts.dialog }}</option> + </MkRadios> + <MkInfo v-if="announcement.display === 'dialog'" warn>{{ i18n.ts._announcement.dialogAnnouncementUxWarn }}</MkInfo> + <MkSwitch v-model="announcement.forExistingUsers" :helpText="i18n.ts._announcement.forExistingUsersDescription"> + {{ i18n.ts._announcement.forExistingUsers }} + </MkSwitch> + <MkSwitch v-model="announcement.silence" :helpText="i18n.ts._announcement.silenceDescription"> + {{ i18n.ts._announcement.silence }} + </MkSwitch> + <MkSwitch v-model="announcement.needConfirmationToRead" :helpText="i18n.ts._announcement.needConfirmationToReadDescription"> + {{ i18n.ts._announcement.needConfirmationToRead }} + </MkSwitch> + <p v-if="announcement.reads">{{ i18n.tsx.nUsersRead({ n: announcement.reads }) }}</p> + <div class="buttons _buttons"> + <MkButton class="button" inline primary @click="save(announcement)"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> + <MkButton v-if="announcement.id != null && announcement.isActive" class="button" inline @click="archive(announcement)"><i class="ti ti-check"></i> {{ i18n.ts._announcement.end }} ({{ i18n.ts.archive }})</MkButton> + <MkButton v-if="announcement.id != null && !announcement.isActive" class="button" inline @click="unarchive(announcement)"><i class="ti ti-restore"></i> {{ i18n.ts.unarchive }}</MkButton> + <MkButton v-if="announcement.id != null" class="button" inline danger @click="del(announcement)"><i class="ti ti-trash"></i> {{ i18n.ts.delete }}</MkButton> + </div> </div> - </div> - </MkFolder> - <MkButton class="button" @click="more()"> - <i class="ti ti-reload"></i>{{ i18n.ts.more }} - </MkButton> + </MkFolder> + <MkLoading v-if="loadingMore"/> + <MkButton class="button" @click="more()"> + <i class="ti ti-reload"></i>{{ i18n.ts.more }} + </MkButton> + </template> </div> </MkSpacer> </MkStickyContainer> </template> <script lang="ts" setup> -import { ref, computed } from 'vue'; +import { ref, computed, watch } from 'vue'; import XHeader from './_header_.vue'; import MkButton from '@/components/MkButton.vue'; import MkInput from '@/components/MkInput.vue'; +import MkSelect from '@/components/MkSelect.vue'; import MkSwitch from '@/components/MkSwitch.vue'; import MkRadios from '@/components/MkRadios.vue'; import MkInfo from '@/components/MkInfo.vue'; @@ -85,11 +98,22 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; import MkFolder from '@/components/MkFolder.vue'; import MkTextarea from '@/components/MkTextarea.vue'; +const announcementsStatus = ref<'active' | 'archived'>('active'); + +const loading = ref(true); +const loadingMore = ref(false); + const announcements = ref<any[]>([]); -misskeyApi('admin/announcements/list').then(announcementResponse => { - announcements.value = announcementResponse; -}); +watch(announcementsStatus, (to) => { + loading.value = true; + misskeyApi('admin/announcements/list', { + status: to, + }).then(announcementResponse => { + announcements.value = announcementResponse; + loading.value = false; + }); +}, { immediate: true }); function add() { announcements.value.unshift({ @@ -125,6 +149,14 @@ async function archive(announcement) { refresh(); } +async function unarchive(announcement) { + await os.apiWithDialog('admin/announcements/update', { + ...announcement, + isActive: true, + }); + refresh(); +} + async function save(announcement) { if (announcement.id == null) { await os.apiWithDialog('admin/announcements/create', announcement); @@ -135,24 +167,32 @@ async function save(announcement) { } function more() { - misskeyApi('admin/announcements/list', { untilId: announcements.value.reduce((acc, announcement) => announcement.id != null ? announcement : acc).id }).then(announcementResponse => { + loadingMore.value = true; + misskeyApi('admin/announcements/list', { + status: announcementsStatus.value, + untilId: announcements.value.reduce((acc, announcement) => announcement.id != null ? announcement : acc).id + }).then(announcementResponse => { announcements.value = announcements.value.concat(announcementResponse); + loadingMore.value = false; }); } function refresh() { - misskeyApi('admin/announcements/list').then(announcementResponse => { + loading.value = true; + misskeyApi('admin/announcements/list', { + status: announcementsStatus.value, + }).then(announcementResponse => { announcements.value = announcementResponse; + loading.value = false; }); } -refresh(); - const headerActions = computed(() => [{ asFullButton: true, icon: 'ti ti-plus', text: i18n.ts.add, handler: add, + disabled: announcementsStatus.value === 'archived', }]); const headerTabs = computed(() => []); diff --git a/packages/frontend/src/pages/admin/federation.vue b/packages/frontend/src/pages/admin/federation.vue index 141de0be34..1902c97724 100644 --- a/packages/frontend/src/pages/admin/federation.vue +++ b/packages/frontend/src/pages/admin/federation.vue @@ -81,9 +81,9 @@ const pagination = { sort: sort.value, host: host.value !== '' ? host.value : null, ...( - state.value === 'federating' ? { federating: true } : - state.value === 'subscribing' ? { subscribing: true } : - state.value === 'publishing' ? { publishing: true } : + state.value === 'federating' ? { federating: true, suspended: false, blocked: false } : + state.value === 'subscribing' ? { subscribing: true, suspended: false, blocked: false } : + state.value === 'publishing' ? { publishing: true, suspended: false, blocked: false } : state.value === 'suspended' ? { suspended: true } : state.value === 'blocked' ? { blocked: true } : state.value === 'silenced' ? { silenced: true } : diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index c5bb2766dc..794669d6b5 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -230,6 +230,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/instance-block.vue b/packages/frontend/src/pages/admin/instance-block.vue index 6b14bd42c2..e090616b26 100644 --- a/packages/frontend/src/pages/admin/instance-block.vue +++ b/packages/frontend/src/pages/admin/instance-block.vue @@ -8,14 +8,22 @@ SPDX-License-Identifier: AGPL-3.0-only <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <MkSpacer :contentMax="700" :marginMin="16" :marginMax="32"> <FormSuspense :p="init"> - <MkTextarea v-if="tab === 'block'" v-model="blockedHosts"> - <span>{{ i18n.ts.blockedInstances }}</span> - <template #caption>{{ i18n.ts.blockedInstancesDescription }}</template> - </MkTextarea> - <MkTextarea v-else-if="tab === 'silence'" v-model="silencedHosts" class="_formBlock"> - <span>{{ i18n.ts.silencedInstances }}</span> - <template #caption>{{ i18n.ts.silencedInstancesDescription }}</template> - </MkTextarea> + <template v-if="tab === 'block'"> + <MkTextarea v-model="blockedHosts"> + <span>{{ i18n.ts.blockedInstances }}</span> + <template #caption>{{ i18n.ts.blockedInstancesDescription }}</template> + </MkTextarea> + </template> + <template v-else-if="tab === 'silence'"> + <MkTextarea v-model="silencedHosts" class="_formBlock"> + <span>{{ i18n.ts.silencedInstances }}</span> + <template #caption>{{ i18n.ts.silencedInstancesDescription }}</template> + </MkTextarea> + <MkTextarea v-model="mediaSilencedHosts" class="_formBlock"> + <span>{{ i18n.ts.mediaSilencedInstances }}</span> + <template #caption>{{ i18n.ts.mediaSilencedInstancesDescription }}</template> + </MkTextarea> + </template> <MkButton primary @click="save"><i class="ti ti-device-floppy"></i> {{ i18n.ts.save }}</MkButton> </FormSuspense> </MkSpacer> @@ -36,18 +44,21 @@ import { definePageMetadata } from '@/scripts/page-metadata.js'; const blockedHosts = ref<string>(''); const silencedHosts = ref<string>(''); +const mediaSilencedHosts = ref<string>(''); const tab = ref('block'); async function init() { const meta = await misskeyApi('admin/meta'); blockedHosts.value = meta.blockedHosts.join('\n'); silencedHosts.value = meta.silencedHosts.join('\n'); + mediaSilencedHosts.value = meta.mediaSilencedHosts.join('\n'); } function save() { os.apiWithDialog('admin/update-meta', { blockedHosts: blockedHosts.value.split('\n') || [], silencedHosts: silencedHosts.value.split('\n') || [], + mediaSilencedHosts: mediaSilencedHosts.value.split('\n') || [], }).then(() => { fetchInstance(true); diff --git a/packages/frontend/src/pages/admin/invites.vue b/packages/frontend/src/pages/admin/invites.vue index 9f172140bc..c4f2c292e0 100644 --- a/packages/frontend/src/pages/admin/invites.vue +++ b/packages/frontend/src/pages/admin/invites.vue @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkInput v-if="!noExpirationDate" v-model="expiresAt" type="datetime-local"> <template #label>{{ i18n.ts.expirationDate }}</template> </MkInput> - <MkInput v-model="createCount" type="number"> + <MkInput v-model="createCount" type="number" min="1"> <template #label>{{ i18n.ts.createCount }}</template> </MkInput> <MkButton primary rounded @click="createWithOptions">{{ i18n.ts.create }}</MkButton> diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index c1e633df23..4cddca3a7d 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -8,9 +8,36 @@ 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', 'approve', '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', + 'approve', + '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> @@ -41,6 +68,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"/> @@ -120,6 +153,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/roles.editor.vue b/packages/frontend/src/pages/admin/roles.editor.vue index 0bff6e39aa..8200244cd7 100644 --- a/packages/frontend/src/pages/admin/roles.editor.vue +++ b/packages/frontend/src/pages/admin/roles.editor.vue @@ -418,6 +418,26 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canUpdateBioMedia, 'canUpdateBioMedia'])"> + <template #label>{{ i18n.ts._role._options.canUpdateBioMedia }}</template> + <template #suffix> + <span v-if="role.policies.canUpdateBioMedia.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span> + <span v-else>{{ role.policies.canUpdateBioMedia.value ? i18n.ts.yes : i18n.ts.no }}</span> + <span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canUpdateBioMedia)"></i></span> + </template> + <div class="_gaps"> + <MkSwitch v-model="role.policies.canUpdateBioMedia.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts._role.useBaseValue }}</template> + </MkSwitch> + <MkSwitch v-model="role.policies.canUpdateBioMedia.value" :disabled="role.policies.canUpdateBioMedia.useDefault" :readonly="readonly"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + <MkRange v-model="role.policies.canUpdateBioMedia.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.pinMax, 'pinLimit'])"> <template #label>{{ i18n.ts._role._options.pinMax }}</template> <template #suffix> diff --git a/packages/frontend/src/pages/admin/roles.vue b/packages/frontend/src/pages/admin/roles.vue index ffd7577689..0a8bd0e898 100644 --- a/packages/frontend/src/pages/admin/roles.vue +++ b/packages/frontend/src/pages/admin/roles.vue @@ -153,6 +153,14 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSwitch> </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.canUpdateBioMedia, 'canUpdateBioMedia'])"> + <template #label>{{ i18n.ts._role._options.canUpdateBioMedia }}</template> + <template #suffix>{{ policies.canUpdateBioMedia ? i18n.ts.yes : i18n.ts.no }}</template> + <MkSwitch v-model="policies.canUpdateBioMedia"> + <template #label>{{ i18n.ts.enable }}</template> + </MkSwitch> + </MkFolder> + <MkFolder v-if="matchQuery([i18n.ts._role._options.pinMax, 'pinLimit'])"> <template #label>{{ i18n.ts._role._options.pinMax }}</template> <template #suffix>{{ policies.pinLimit }}</template> @@ -263,7 +271,7 @@ import * as os from '@/os.js'; import { misskeyApi } from '@/scripts/misskey-api.js'; import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; -import { instance } from '@/instance.js'; +import { instance, fetchInstance } from '@/instance.js'; import MkFoldableSection from '@/components/MkFoldableSection.vue'; import { ROLE_POLICIES } from '@/const.js'; import { useRouter } from '@/router/supplier.js'; @@ -287,6 +295,7 @@ async function updateBaseRole() { await os.apiWithDialog('admin/roles/update-default-policies', { policies, }); + fetchInstance(true); } function create() { 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> |