summaryrefslogtreecommitdiff
path: root/packages/frontend/src/pages/admin
diff options
context:
space:
mode:
authordakkar <dakkar@thenautilus.net>2024-08-02 12:25:58 +0100
committerdakkar <dakkar@thenautilus.net>2024-08-02 12:25:58 +0100
commitcfa9b852df9e0293865b3acbd67d59265962e552 (patch)
treea408ad670956a45c4e162e4ecc97a3624e2b0f20 /packages/frontend/src/pages/admin
parentmerge: rate limit all password checks - fixes #540 (!568) (diff)
parentMerge pull request #14233 from misskey-dev/develop (diff)
downloadsharkey-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')
-rw-r--r--packages/frontend/src/pages/admin/_header_.vue5
-rw-r--r--packages/frontend/src/pages/admin/abuse-report/notification-recipient.editor.vue321
-rw-r--r--packages/frontend/src/pages/admin/abuse-report/notification-recipient.item.vue114
-rw-r--r--packages/frontend/src/pages/admin/abuse-report/notification-recipient.vue177
-rw-r--r--packages/frontend/src/pages/admin/abuses.vue85
-rw-r--r--packages/frontend/src/pages/admin/announcements.vue160
-rw-r--r--packages/frontend/src/pages/admin/federation.vue6
-rw-r--r--packages/frontend/src/pages/admin/index.vue5
-rw-r--r--packages/frontend/src/pages/admin/instance-block.vue27
-rw-r--r--packages/frontend/src/pages/admin/invites.vue2
-rw-r--r--packages/frontend/src/pages/admin/modlog.ModLog.vue49
-rw-r--r--packages/frontend/src/pages/admin/roles.editor.vue20
-rw-r--r--packages/frontend/src/pages/admin/roles.vue11
-rw-r--r--packages/frontend/src/pages/admin/system-webhook.item.vue117
-rw-r--r--packages/frontend/src/pages/admin/system-webhook.vue96
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>