summaryrefslogtreecommitdiff
path: root/packages/frontend/src/pages/admin-user.vue
diff options
context:
space:
mode:
authorsyuilo <Syuilotan@yahoo.co.jp>2023-08-13 21:02:25 +0900
committersyuilo <Syuilotan@yahoo.co.jp>2023-08-13 21:02:25 +0900
commitbbef2a953ebf2f7f063641e6764ec8571cd78b75 (patch)
treeb2fc17e8c413c3a92a5d5e619eea5a0ee79336ec /packages/frontend/src/pages/admin-user.vue
parentfix(frontend): fix style of _error_.vue (diff)
downloadmisskey-bbef2a953ebf2f7f063641e6764ec8571cd78b75.tar.gz
misskey-bbef2a953ebf2f7f063641e6764ec8571cd78b75.tar.bz2
misskey-bbef2a953ebf2f7f063641e6764ec8571cd78b75.zip
enhance(frontend): tweak user moderation page
Diffstat (limited to 'packages/frontend/src/pages/admin-user.vue')
-rw-r--r--packages/frontend/src/pages/admin-user.vue614
1 files changed, 614 insertions, 0 deletions
diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue
new file mode 100644
index 0000000000..1b79a14f55
--- /dev/null
+++ b/packages/frontend/src/pages/admin-user.vue
@@ -0,0 +1,614 @@
+<!--
+SPDX-FileCopyrightText: syuilo and other misskey contributors
+SPDX-License-Identifier: AGPL-3.0-only
+-->
+
+<template>
+<MkStickyContainer>
+ <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
+ <MkSpacer :contentMax="600" :marginMin="16" :marginMax="32">
+ <FormSuspense :p="init">
+ <div v-if="tab === 'overview'" class="_gaps_m">
+ <div class="aeakzknw">
+ <MkAvatar class="avatar" :user="user" indicator link preview/>
+ <div class="body">
+ <span class="name"><MkUserName class="name" :user="user"/></span>
+ <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span>
+ <span class="state">
+ <span v-if="suspended" class="suspended">Suspended</span>
+ <span v-if="silenced" class="silenced">Silenced</span>
+ <span v-if="moderator" class="moderator">Moderator</span>
+ </span>
+ </div>
+ </div>
+
+ <MkInfo v-if="user.username.includes('.')">{{ i18n.ts.isSystemAccount }}</MkInfo>
+
+ <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink>
+
+ <div style="display: flex; flex-direction: column; gap: 1em;">
+ <MkKeyValue :copy="user.id" oneline>
+ <template #key>ID</template>
+ <template #value><span class="_monospace">{{ user.id }}</span></template>
+ </MkKeyValue>
+ <!-- 要る?
+ <MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline>
+ <template #key>IP (recent)</template>
+ <template #value><span class="_monospace">{{ ips[0].ip }}</span></template>
+ </MkKeyValue>
+ -->
+ <MkKeyValue oneline>
+ <template #key>{{ i18n.ts.createdAt }}</template>
+ <template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template>
+ </MkKeyValue>
+ <MkKeyValue v-if="info" oneline>
+ <template #key>{{ i18n.ts.lastActiveDate }}</template>
+ <template #value><span class="_monospace"><MkTime :time="info.lastActiveDate" :mode="'detail'"/></span></template>
+ </MkKeyValue>
+ <MkKeyValue v-if="info" oneline>
+ <template #key>{{ i18n.ts.email }}</template>
+ <template #value><span class="_monospace">{{ info.email }}</span></template>
+ </MkKeyValue>
+ </div>
+
+ <MkTextarea v-model="moderationNote" manualSave>
+ <template #label>Moderation note</template>
+ </MkTextarea>
+
+ <!--
+ <FormSection>
+ <template #label>ActivityPub</template>
+
+ <div class="_gaps_m">
+ <div style="display: flex; flex-direction: column; gap: 1em;">
+ <MkKeyValue v-if="user.host" oneline>
+ <template #key>{{ i18n.ts.instanceInfo }}</template>
+ <template #value><MkA :to="`/instance-info/${user.host}`" class="_link">{{ user.host }} <i class="ti ti-chevron-right"></i></MkA></template>
+ </MkKeyValue>
+ <MkKeyValue v-else oneline>
+ <template #key>{{ i18n.ts.instanceInfo }}</template>
+ <template #value>(Local user)</template>
+ </MkKeyValue>
+ <MkKeyValue oneline>
+ <template #key>{{ i18n.ts.updatedAt }}</template>
+ <template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template>
+ </MkKeyValue>
+ <MkKeyValue v-if="ap" oneline>
+ <template #key>Type</template>
+ <template #value><span class="_monospace">{{ ap.type }}</span></template>
+ </MkKeyValue>
+ </div>
+
+ <MkButton v-if="user.host != null" @click="updateRemoteUser"><i class="ti ti-refresh"></i> {{ i18n.ts.updateRemoteUser }}</MkButton>
+
+ <MkFolder>
+ <template #label>Raw</template>
+
+ <MkObjectView v-if="ap" tall :value="ap">
+ </MkObjectView>
+ </MkFolder>
+ </div>
+ </FormSection>
+ -->
+
+ <FormSection>
+ <div class="_gaps">
+ <MkSwitch v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch>
+
+ <div>
+ <MkButton v-if="user.host == null" inline style="margin-right: 8px;" @click="resetPassword"><i class="ti ti-key"></i> {{ i18n.ts.resetPassword }}</MkButton>
+ </div>
+
+ <MkFolder>
+ <template #icon><i class="ti ti-license"></i></template>
+ <template #label>{{ i18n.ts._role.policies }}</template>
+ <div class="_gaps">
+ <div v-for="policy in Object.keys(info.policies)" :key="policy">
+ {{ policy }} ... {{ info.policies[policy] }}
+ </div>
+ </div>
+ </MkFolder>
+
+ <MkFolder>
+ <template #icon><i class="ti ti-password"></i></template>
+ <template #label>IP</template>
+ <MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo>
+ <MkInfo v-else>The date is the IP address was first acknowledged.</MkInfo>
+ <template v-if="iAmAdmin && ips">
+ <div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;">
+ <span class="date">{{ record.createdAt }}</span>
+ <span class="ip">{{ record.ip }}</span>
+ </div>
+ </template>
+ </MkFolder>
+
+ <MkButton v-if="$i.isAdmin" inline danger @click="deleteAccount">{{ i18n.ts.deleteAccount }}</MkButton>
+ </div>
+ </FormSection>
+ </div>
+
+ <div v-else-if="tab === 'roles'" class="_gaps">
+ <MkButton v-if="user.host == null" primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
+
+ <div v-for="role in info.roles" :key="role.id" :class="$style.roleItem">
+ <div :class="$style.roleItemMain">
+ <MkRolePreview :class="$style.role" :role="role" :forModeration="true"/>
+ <button class="_button" :class="$style.roleToggle" @click="toggleRoleItem(role)"><i class="ti ti-chevron-down"></i></button>
+ <button v-if="role.target === 'manual'" class="_button" :class="$style.roleUnassign" @click="unassignRole(role, $event)"><i class="ti ti-x"></i></button>
+ <button v-else class="_button" :class="$style.roleUnassign" disabled><i class="ti ti-ban"></i></button>
+ </div>
+ <div v-if="expandedRoles.includes(role.id)" :class="$style.roleItemSub">
+ <div>Assigned: <MkTime :time="info.roleAssigns.find(a => a.roleId === role.id).createdAt" mode="detail"/></div>
+ <div v-if="info.roleAssigns.find(a => a.roleId === role.id).expiresAt">Period: {{ new Date(info.roleAssigns.find(a => a.roleId === role.id).expiresAt).toLocaleString() }}</div>
+ <div v-else>Period: {{ i18n.ts.indefinitely }}</div>
+ </div>
+ </div>
+ </div>
+
+ <div v-else-if="tab === 'announcements'" class="_gaps">
+ <MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts.new }}</MkButton>
+
+ <MkPagination :pagination="announcementsPagination">
+ <template #default="{ items }">
+ <div class="_gaps_s">
+ <div v-for="announcement in items" :key="announcement.id" v-panel :class="$style.announcementItem" @click="editAnnouncement(announcement)">
+ <span style="margin-right: 0.5em;">
+ <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>
+ </span>
+ <span>{{ announcement.title }}</span>
+ <span v-if="announcement.reads > 0" style="margin-left: auto; opacity: 0.7;">{{ i18n.ts.messageRead }}</span>
+ </div>
+ </div>
+ </template>
+ </MkPagination>
+ </div>
+
+ <div v-else-if="tab === 'drive'" class="_gaps">
+ <MkFileListForAdmin :pagination="filesPagination" viewMode="grid"/>
+ </div>
+
+ <div v-else-if="tab === 'chart'" class="_gaps_m">
+ <div class="cmhjzshm">
+ <div class="selects">
+ <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;">
+ <option value="per-user-notes">{{ i18n.ts.notes }}</option>
+ </MkSelect>
+ </div>
+ <div class="charts">
+ <div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div>
+ <MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart>
+ <div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div>
+ <MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart>
+ </div>
+ </div>
+ </div>
+
+ <div v-else-if="tab === 'raw'" class="_gaps_m">
+ <MkObjectView v-if="info && $i.isAdmin" tall :value="info">
+ </MkObjectView>
+
+ <MkObjectView tall :value="user">
+ </MkObjectView>
+ </div>
+ </FormSuspense>
+ </MkSpacer>
+</MkStickyContainer>
+</template>
+
+<script lang="ts" setup>
+import { computed, defineAsyncComponent, watch } from 'vue';
+import * as misskey from 'misskey-js';
+import MkChart from '@/components/MkChart.vue';
+import MkObjectView from '@/components/MkObjectView.vue';
+import MkTextarea from '@/components/MkTextarea.vue';
+import MkSwitch from '@/components/MkSwitch.vue';
+import FormLink from '@/components/form/link.vue';
+import FormSection from '@/components/form/section.vue';
+import MkButton from '@/components/MkButton.vue';
+import MkFolder from '@/components/MkFolder.vue';
+import MkKeyValue from '@/components/MkKeyValue.vue';
+import MkSelect from '@/components/MkSelect.vue';
+import FormSuspense from '@/components/form/suspense.vue';
+import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
+import MkInfo from '@/components/MkInfo.vue';
+import * as os from '@/os';
+import { url } from '@/config';
+import { userPage, acct } from '@/filters/user';
+import { definePageMetadata } from '@/scripts/page-metadata';
+import { i18n } from '@/i18n';
+import { iAmAdmin, $i } from '@/account';
+import MkRolePreview from '@/components/MkRolePreview.vue';
+import MkPagination, { Paging } from '@/components/MkPagination.vue';
+
+const props = withDefaults(defineProps<{
+ userId: string;
+ initialTab?: string;
+}>(), {
+ initialTab: 'overview',
+});
+
+let tab = $ref(props.initialTab);
+let chartSrc = $ref('per-user-notes');
+let user = $ref<null | misskey.entities.UserDetailed>();
+let init = $ref<ReturnType<typeof createFetcher>>();
+let info = $ref();
+let ips = $ref(null);
+let ap = $ref(null);
+let moderator = $ref(false);
+let silenced = $ref(false);
+let suspended = $ref(false);
+let moderationNote = $ref('');
+const filesPagination = {
+ endpoint: 'admin/drive/files' as const,
+ limit: 10,
+ params: computed(() => ({
+ userId: props.userId,
+ })),
+};
+const announcementsPagination = {
+ endpoint: 'admin/announcements/list' as const,
+ limit: 10,
+ params: computed(() => ({
+ userId: props.userId,
+ })),
+};
+let expandedRoles = $ref([]);
+
+function createFetcher() {
+ return () => Promise.all([os.api('users/show', {
+ userId: props.userId,
+ }), os.api('admin/show-user', {
+ userId: props.userId,
+ }), iAmAdmin ? os.api('admin/get-user-ips', {
+ userId: props.userId,
+ }) : Promise.resolve(null)]).then(([_user, _info, _ips]) => {
+ user = _user;
+ info = _info;
+ ips = _ips;
+ moderator = info.isModerator;
+ silenced = info.isSilenced;
+ suspended = info.isSuspended;
+ moderationNote = info.moderationNote;
+
+ watch($$(moderationNote), async () => {
+ await os.api('admin/update-user-note', { userId: user.id, text: moderationNote });
+ await refreshUser();
+ });
+ });
+}
+
+function refreshUser() {
+ init = createFetcher();
+}
+
+async function updateRemoteUser() {
+ await os.apiWithDialog('federation/update-remote-user', { userId: user.id });
+ refreshUser();
+}
+
+async function resetPassword() {
+ const confirm = await os.confirm({
+ type: 'warning',
+ text: i18n.ts.resetPasswordConfirm,
+ });
+ if (confirm.canceled) {
+ return;
+ } else {
+ const { password } = await os.api('admin/reset-password', {
+ userId: user.id,
+ });
+ os.alert({
+ type: 'success',
+ text: i18n.t('newPasswordIs', { password }),
+ });
+ }
+}
+
+async function toggleSuspend(v) {
+ const confirm = await os.confirm({
+ type: 'warning',
+ text: v ? i18n.ts.suspendConfirm : i18n.ts.unsuspendConfirm,
+ });
+ if (confirm.canceled) {
+ suspended = !v;
+ } else {
+ await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: user.id });
+ await refreshUser();
+ }
+}
+
+async function deleteAllFiles() {
+ const confirm = await os.confirm({
+ type: 'warning',
+ text: i18n.ts.deleteAllFilesConfirm,
+ });
+ if (confirm.canceled) return;
+ const process = async () => {
+ await os.api('admin/delete-all-files-of-a-user', { userId: user.id });
+ os.success();
+ };
+ await process().catch(err => {
+ os.alert({
+ type: 'error',
+ text: err.toString(),
+ });
+ });
+ await refreshUser();
+}
+
+async function deleteAccount() {
+ const confirm = await os.confirm({
+ type: 'warning',
+ text: i18n.ts.deleteAccountConfirm,
+ });
+ if (confirm.canceled) return;
+
+ const typed = await os.inputText({
+ text: i18n.t('typeToConfirm', { x: user?.username }),
+ });
+ if (typed.canceled) return;
+
+ if (typed.result === user?.username) {
+ await os.apiWithDialog('admin/delete-account', {
+ userId: user.id,
+ });
+ } else {
+ os.alert({
+ type: 'error',
+ text: 'input not match',
+ });
+ }
+}
+
+async function assignRole() {
+ const roles = await os.api('admin/roles/list');
+
+ const { canceled, result: roleId } = await os.select({
+ title: i18n.ts._role.chooseRoleToAssign,
+ items: roles.map(r => ({ text: r.name, value: r.id })),
+ });
+ if (canceled) return;
+
+ const { canceled: canceled2, result: period } = await os.select({
+ title: i18n.ts.period,
+ items: [{
+ value: 'indefinitely', text: i18n.ts.indefinitely,
+ }, {
+ value: 'oneHour', text: i18n.ts.oneHour,
+ }, {
+ value: 'oneDay', text: i18n.ts.oneDay,
+ }, {
+ value: 'oneWeek', text: i18n.ts.oneWeek,
+ }, {
+ value: 'oneMonth', text: i18n.ts.oneMonth,
+ }],
+ default: 'indefinitely',
+ });
+ if (canceled2) return;
+
+ const expiresAt = period === 'indefinitely' ? null
+ : period === 'oneHour' ? Date.now() + (1000 * 60 * 60)
+ : period === 'oneDay' ? Date.now() + (1000 * 60 * 60 * 24)
+ : period === 'oneWeek' ? Date.now() + (1000 * 60 * 60 * 24 * 7)
+ : period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30)
+ : null;
+
+ await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.id, expiresAt });
+ refreshUser();
+}
+
+async function unassignRole(role, ev) {
+ os.popupMenu([{
+ text: i18n.ts.unassign,
+ icon: 'ti ti-x',
+ danger: true,
+ action: async () => {
+ await os.apiWithDialog('admin/roles/unassign', { roleId: role.id, userId: user.id });
+ refreshUser();
+ },
+ }], ev.currentTarget ?? ev.target);
+}
+
+function toggleRoleItem(role) {
+ if (expandedRoles.includes(role.id)) {
+ expandedRoles = expandedRoles.filter(x => x !== role.id);
+ } else {
+ expandedRoles.push(role.id);
+ }
+}
+
+function createAnnouncement() {
+ os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), {
+ user,
+ }, {}, 'closed');
+}
+
+function editAnnouncement(announcement) {
+ os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), {
+ user,
+ announcement,
+ }, {}, 'closed');
+}
+
+watch(() => props.userId, () => {
+ init = createFetcher();
+}, {
+ immediate: true,
+});
+
+watch($$(user), () => {
+ os.api('ap/get', {
+ uri: user.uri ?? `${url}/users/${user.id}`,
+ }).then(res => {
+ ap = res;
+ });
+});
+
+const headerActions = $computed(() => []);
+
+const headerTabs = $computed(() => [{
+ key: 'overview',
+ title: i18n.ts.overview,
+ icon: 'ti ti-info-circle',
+}, {
+ key: 'roles',
+ title: i18n.ts.roles,
+ icon: 'ti ti-badges',
+}, {
+ key: 'announcements',
+ title: i18n.ts.announcements,
+ icon: 'ti ti-speakerphone',
+}, {
+ key: 'drive',
+ title: i18n.ts.drive,
+ icon: 'ti ti-cloud',
+}, {
+ key: 'chart',
+ title: i18n.ts.charts,
+ icon: 'ti ti-chart-line',
+}, {
+ key: 'raw',
+ title: 'Raw',
+ icon: 'ti ti-code',
+}]);
+
+definePageMetadata(computed(() => ({
+ title: user ? acct(user) : i18n.ts.userInfo,
+ icon: 'ti ti-user-exclamation',
+})));
+</script>
+
+<style lang="scss" scoped>
+.aeakzknw {
+ display: flex;
+ align-items: center;
+
+ > .avatar {
+ display: block;
+ width: 64px;
+ height: 64px;
+ margin-right: 16px;
+ }
+
+ > .body {
+ flex: 1;
+ overflow: hidden;
+
+ > .name {
+ display: block;
+ width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ > .sub {
+ display: block;
+ width: 100%;
+ font-size: 85%;
+ opacity: 0.7;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ > .state {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin-top: 4px;
+
+ &:empty {
+ display: none;
+ }
+
+ > .suspended, > .silenced, > .moderator {
+ display: inline-block;
+ border: solid 1px;
+ border-radius: 6px;
+ padding: 2px 6px;
+ font-size: 85%;
+ }
+
+ > .suspended {
+ color: var(--error);
+ border-color: var(--error);
+ }
+
+ > .silenced {
+ color: var(--warn);
+ border-color: var(--warn);
+ }
+
+ > .moderator {
+ color: var(--success);
+ border-color: var(--success);
+ }
+ }
+ }
+}
+
+.cmhjzshm {
+ > .selects {
+ display: flex;
+ margin: 0 0 16px 0;
+ }
+
+ > .charts {
+ > .label {
+ margin-bottom: 12px;
+ font-weight: bold;
+ }
+ }
+}
+</style>
+
+<style lang="scss" module>
+.ip {
+ display: flex;
+
+ > :global(.date) {
+ opacity: 0.7;
+ }
+
+ > :global(.ip) {
+ margin-left: auto;
+ }
+}
+
+.roleItem {
+}
+
+.roleItemMain {
+ display: flex;
+}
+
+.role {
+ flex: 1;
+ min-width: 0;
+ margin-right: 8px;
+}
+
+.roleItemSub {
+ padding: 6px 12px;
+ font-size: 85%;
+ color: var(--fgTransparentWeak);
+}
+
+.roleUnassign {
+ width: 32px;
+ height: 32px;
+ margin-left: 8px;
+ align-self: center;
+}
+
+.announcementItem {
+ display: flex;
+ padding: 8px 12px;
+ border-radius: 6px;
+ cursor: pointer;
+}
+</style>