diff options
Diffstat (limited to 'packages/frontend/src/pages/admin-user.vue')
| -rw-r--r-- | packages/frontend/src/pages/admin-user.vue | 361 |
1 files changed, 254 insertions, 107 deletions
diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index d3c0de3040..dc29ae2f80 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -4,11 +4,11 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"> - <div class="_spacer" style="--MI_SPACER-w: 600px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> - <FormSuspense :p="init"> +<PageWithHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs" :spacer="true" style="--MI_SPACER-w: 700px; --MI_SPACER-min: 16px; --MI_SPACER-max: 32px;"> + <FormSuspense v-if="init" :p="init"> + <div v-if="user && info"> <div v-if="tab === 'overview'" class="_gaps"> - <div v-if="user" class="aeakzknw"> + <div class="aeakzknw"> <MkAvatar class="avatar" :user="user" indicator link preview/> <div class="body"> <span class="name"><MkUserName class="name" :user="user"/></span> @@ -20,19 +20,14 @@ SPDX-License-Identifier: AGPL-3.0-only <span class="_monospace">{{ user.id }}</span> <button v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copyToClipboard(user.id)"><i class="ti ti-copy"></i></button> </span> - <span class="state"> - <span v-if="!approved" class="silenced">{{ i18n.ts.notApproved }}</span> - <span v-if="approved && !user.host" class="moderator">{{ i18n.ts.approved }}</span> - <span v-if="suspended" class="suspended">{{ i18n.ts.suspended }}</span> - <span v-if="silenced" class="silenced">{{ i18n.ts.silenced }}</span> - <span v-if="moderator" class="moderator">{{ i18n.ts.moderator }}</span> - </span> </div> </div> + <SkBadgeStrip v-if="badges.length > 0" :badges="badges"></SkBadgeStrip> + <MkInfo v-if="isSystem">{{ i18n.ts.isSystemAccount }}</MkInfo> - <MkFolder v-if="!isSystem"> + <MkFolder v-if="!isSystem" :sticky="false"> <template #icon><i class="ph-list-bullets ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.details }}</template> <div style="display: flex; flex-direction: column; gap: 1em;"> @@ -89,7 +84,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> - <MkFolder v-if="info"> + <MkFolder v-if="info" :sticky="false"> <template #icon><i class="ph-scroll ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts._role.policies }}</template> <div class="_gaps"> @@ -99,7 +94,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> - <MkFolder v-if="iAmAdmin && ips && ips.length > 0"> + <MkFolder v-if="iAmAdmin && ips && ips.length > 0" :sticky="false"> <template #icon><i class="ph-network ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.ip }}</template> <MkInfo>{{ i18n.ts.ipTip }}</MkInfo> @@ -109,7 +104,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </MkFolder> - <MkFolder v-if="iAmModerator" :defaultOpen="moderationNote.length > 0"> + <MkFolder v-if="iAmModerator" :defaultOpen="moderationNote.length > 0" :sticky="false"> <template #icon><i class="ph-stamp ph-bold ph-lg"></i></template> <template #label>{{ i18n.ts.moderationNote }}</template> <MkTextarea v-model="moderationNote" manualSave @update:modelValue="onModerationNoteChanged"> @@ -135,6 +130,11 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </FormSection> + <FormSection v-else-if="info.signupReason"> + <template #label>{{ i18n.ts.signupReason }}</template> + {{ info.signupReason }} + </FormSection> + <FormSection v-if="!isSystem && user && iAmModerator"> <div class="_gaps"> <MkSwitch v-model="silenced" @update:modelValue="toggleSilence">{{ i18n.ts.silence }}</MkSwitch> @@ -233,14 +233,46 @@ SPDX-License-Identifier: AGPL-3.0-only </div> <div v-else-if="tab === 'raw'" class="_gaps_m"> - <MkObjectView v-if="info && $i.isAdmin" tall :value="info"> - </MkObjectView> + <MkFolder :sticky="false" :defaultOpen="true"> + <template #icon><i class="ph-user-circle ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts.user }}</template> + <template #header> + <div :class="$style.rawFolderHeader"> + <span>{{ i18n.ts.rawUserDescription }}</span> + <button class="_textButton" @click="copyToClipboard(JSON.stringify(user, null, 4))"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</button> + </div> + </template> + + <MkObjectView tall :value="user"/> + </MkFolder> - <MkObjectView tall :value="user"> - </MkObjectView> + <MkFolder :sticky="false"> + <template #icon><i class="ti ti-info-circle"></i></template> + <template #label>{{ i18n.ts.details }}</template> + <template #header> + <div :class="$style.rawFolderHeader"> + <span>{{ i18n.ts.rawInfoDescription }}</span> + <button class="_textButton" @click="copyToClipboard(JSON.stringify(info, null, 4))"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</button> + </div> + </template> + + <MkObjectView tall :value="info"/> + </MkFolder> + + <MkFolder v-if="ap" :sticky="false"> + <template #icon><i class="ph-globe ph-bold ph-lg"></i></template> + <template #label>{{ i18n.ts.activityPub }}</template> + <template #header> + <div :class="$style.rawFolderHeader"> + <span>{{ i18n.ts.rawApDescription }}</span> + <button class="_textButton" @click="copyToClipboard(JSON.stringify(ap, null, 4))"><i class="ti ti-copy"></i> {{ i18n.ts.copy }}</button> + </div> + </template> + <MkObjectView tall :value="ap"/> + </MkFolder> </div> - </FormSuspense> - </div> + </div> + </FormSuspense> </PageWithHeader> </template> @@ -248,6 +280,8 @@ SPDX-License-Identifier: AGPL-3.0-only import { computed, defineAsyncComponent, watch, ref } from 'vue'; import * as Misskey from 'misskey-js'; import { url } from '@@/js/config.js'; +import type { Badge } from '@/components/SkBadgeStrip.vue'; +import type { ChartSrc } from '@/components/MkChart.vue'; import MkChart from '@/components/MkChart.vue'; import MkObjectView from '@/components/MkObjectView.vue'; import MkTextarea from '@/components/MkTextarea.vue'; @@ -272,16 +306,25 @@ import MkPagination from '@/components/MkPagination.vue'; import MkInput from '@/components/MkInput.vue'; import MkNumber from '@/components/MkNumber.vue'; import { copyToClipboard } from '@/utility/copy-to-clipboard'; +import SkBadgeStrip from '@/components/SkBadgeStrip.vue'; const props = withDefaults(defineProps<{ userId: string; initialTab?: string; + userHint?: Misskey.entities.UserDetailed; + infoHint?: Misskey.entities.AdminShowUserResponse; + ipsHint?: Misskey.entities.AdminGetUserIpsResponse; + apHint?: Misskey.entities.ApGetResponse; }>(), { initialTab: 'overview', + userHint: undefined, + infoHint: undefined, + ipsHint: undefined, + apHint: undefined, }); const tab = ref(props.initialTab); -const chartSrc = ref('per-user-notes'); +const chartSrc = ref<ChartSrc>('per-user-notes'); const user = ref<null | Misskey.entities.UserDetailed>(); const init = ref<ReturnType<typeof createFetcher>>(); const info = ref<Misskey.entities.AdminShowUserResponse | null>(null); @@ -304,6 +347,98 @@ const filesPagination = { })), }; +const badges = computed(() => { + const arr: Badge[] = []; + if (info.value && user.value) { + if (info.value.isSuspended) { + arr.push({ + key: 'suspended', + label: i18n.ts.suspended, + style: 'error', + }); + } + + if (info.value.isSilenced) { + arr.push({ + key: 'silenced', + label: i18n.ts.silenced, + style: 'warning', + }); + } + + if (info.value.alwaysMarkNsfw) { + arr.push({ + key: 'nsfw', + label: i18n.ts.nsfw, + style: 'warning', + }); + } + + if (user.value.mandatoryCW) { + arr.push({ + key: 'cw', + label: i18n.ts.cw, + style: 'warning', + }); + } + + if (info.value.isHibernated) { + arr.push({ + key: 'hibernated', + label: i18n.ts.hibernated, + style: 'neutral', + }); + } + + if (info.value.isAdministrator) { + arr.push({ + key: 'admin', + label: i18n.ts.administrator, + style: 'success', + }); + } else if (info.value.isModerator) { + arr.push({ + key: 'mod', + label: i18n.ts.moderator, + style: 'success', + }); + } + + if (user.value.host == null) { + if (info.value.email) { + if (info.value.emailVerified) { + arr.push({ + key: 'verified', + label: i18n.ts.verified, + style: 'success', + }); + } else { + arr.push({ + key: 'not_verified', + label: i18n.ts.notVerified, + style: 'success', + }); + } + } + + if (info.value.approved) { + arr.push({ + key: 'approved', + label: i18n.ts.approved, + style: 'success', + }); + } else { + arr.push({ + key: 'not_approved', + label: i18n.ts.notApproved, + style: 'warning', + }); + } + } + } + return arr; +}); + const announcementsStatus = ref<'active' | 'archived'>('active'); const announcementsPagination = { @@ -314,47 +449,65 @@ const announcementsPagination = { status: announcementsStatus.value, })), }; -const expandedRoles = ref([]); +const expandedRoles = ref<string[]>([]); -function createFetcher() { - return () => Promise.all([misskeyApi('users/show', { - userId: props.userId, - }), misskeyApi('admin/show-user', { - userId: props.userId, - }), iAmAdmin ? misskeyApi('admin/get-user-ips', { - userId: props.userId, - }) : Promise.resolve(null)]).then(([_user, _info, _ips]) => { +function createFetcher(withHint = true) { + return () => Promise.all([ + (withHint && props.userHint) ? props.userHint : misskeyApi('users/show', { + userId: props.userId, + }), + (withHint && props.infoHint) ? props.infoHint : misskeyApi('admin/show-user', { + userId: props.userId, + }), + iAmAdmin + ? (withHint && props.ipsHint) ? props.ipsHint : misskeyApi('admin/get-user-ips', { + userId: props.userId, + }) + : null, + iAmAdmin + ? (withHint && props.apHint) ? props.apHint : misskeyApi('ap/get', { + userId: props.userId, + }).catch(() => null) : null], + ).then(async ([_user, _info, _ips, _ap]) => { user.value = _user; info.value = _info; ips.value = _ips; - moderator.value = info.value.isModerator; - silenced.value = info.value.isSilenced; - approved.value = info.value.approved; - markedAsNSFW.value = info.value.alwaysMarkNsfw; - suspended.value = info.value.isSuspended; - rejectQuotes.value = user.value.rejectQuotes ?? false; - moderationNote.value = info.value.moderationNote; - mandatoryCW.value = user.value.mandatoryCW; + ap.value = _ap; + moderator.value = _info.isModerator; + silenced.value = _info.isSilenced; + approved.value = _info.approved; + markedAsNSFW.value = _info.alwaysMarkNsfw; + suspended.value = _info.isSuspended; + rejectQuotes.value = _user.rejectQuotes ?? false; + moderationNote.value = _info.moderationNote; + mandatoryCW.value = _user.mandatoryCW; }); } -function refreshUser() { - init.value = createFetcher(); +async function refreshUser() { + // Not a typo - createFetcher() returns a function() + await createFetcher(false)(); } -async function onMandatoryCWChanged(value: string) { - await os.apiWithDialog('admin/cw-user', { userId: props.userId, cw: value }); - refreshUser(); +async function onMandatoryCWChanged(value: string | number) { + await os.promiseDialog(async () => { + await misskeyApi('admin/cw-user', { userId: props.userId, cw: String(value) }); + await refreshUser(); + }); } async function onModerationNoteChanged(value: string) { - await misskeyApi('admin/update-user-note', { userId: props.userId, text: value }); - refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi('admin/update-user-note', { userId: props.userId, text: value }); + await refreshUser(); + }); } async function updateRemoteUser() { - await os.apiWithDialog('federation/update-remote-user', { userId: user.value.id }); - refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi('federation/update-remote-user', { userId: props.userId }); + await refreshUser(); + }); } async function resetPassword() { @@ -366,9 +519,9 @@ async function resetPassword() { return; } else { const { password } = await misskeyApi('admin/reset-password', { - userId: user.value.id, + userId: props.userId, }); - os.alert({ + await os.alert({ type: 'success', text: i18n.tsx.newPasswordIs({ password }), }); @@ -383,7 +536,7 @@ async function toggleNSFW(v) { if (confirm.canceled) { markedAsNSFW.value = !v; } else { - await misskeyApi(v ? 'admin/nsfw-user' : 'admin/unnsfw-user', { userId: user.value.id }); + await misskeyApi(v ? 'admin/nsfw-user' : 'admin/unnsfw-user', { userId: props.userId }); await refreshUser(); } } @@ -396,8 +549,10 @@ async function toggleSilence(v) { if (confirm.canceled) { silenced.value = !v; } else { - await misskeyApi(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: user.value.id }); - await refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: props.userId }); + await refreshUser(); + }); } } @@ -409,8 +564,10 @@ async function toggleSuspend(v) { if (confirm.canceled) { suspended.value = !v; } else { - await misskeyApi(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: user.value.id }); - await refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: props.userId }); + await refreshUser(); + }); } } @@ -422,11 +579,13 @@ async function toggleRejectQuotes(v: boolean): Promise<void> { if (confirm.canceled) { rejectQuotes.value = !v; } else { - await misskeyApi('admin/reject-quotes', { - userId: props.userId, - rejectQuotes: v, + await os.promiseDialog(async () => { + await misskeyApi('admin/reject-quotes', { + userId: props.userId, + rejectQuotes: v, + }); + await refreshUser(); }); - await refreshUser(); } } @@ -436,17 +595,10 @@ async function unsetUserAvatar() { text: i18n.ts.unsetUserAvatarConfirm, }); if (confirm.canceled) return; - const process = async () => { - await misskeyApi('admin/unset-user-avatar', { userId: user.value.id }); - os.success(); - }; - await process().catch(err => { - os.alert({ - type: 'error', - text: err.toString(), - }); + await os.promiseDialog(async () => { + await misskeyApi('admin/unset-user-avatar', { userId: props.userId }); + await refreshUser(); }); - refreshUser(); } async function unsetUserBanner() { @@ -455,17 +607,10 @@ async function unsetUserBanner() { text: i18n.ts.unsetUserBannerConfirm, }); if (confirm.canceled) return; - const process = async () => { - await misskeyApi('admin/unset-user-banner', { userId: user.value.id }); - os.success(); - }; - await process().catch(err => { - os.alert({ - type: 'error', - text: err.toString(), - }); + await os.promiseDialog(async () => { + await misskeyApi('admin/unset-user-banner', { userId: props.userId }); + await refreshUser(); }); - refreshUser(); } async function deleteAllFiles() { @@ -474,17 +619,10 @@ async function deleteAllFiles() { text: i18n.ts.deleteAllFilesConfirm, }); if (confirm.canceled) return; - const process = async () => { - await misskeyApi('admin/delete-all-files-of-a-user', { userId: user.value.id }); - os.success(); - }; - await process().catch(err => { - os.alert({ - type: 'error', - text: err.toString(), - }); + await os.promiseDialog(async () => { + await misskeyApi('admin/delete-all-files-of-a-user', { userId: props.userId }); + await refreshUser(); }); - await refreshUser(); } async function deleteAccount() { @@ -493,18 +631,19 @@ async function deleteAccount() { text: i18n.ts.deleteThisAccountConfirm, }); if (confirm.canceled) return; + if (!user.value) return; const typed = await os.inputText({ - text: i18n.tsx.typeToConfirm({ x: user.value?.username }), + text: i18n.tsx.typeToConfirm({ x: user.value.username }), }); if (typed.canceled) return; - if (typed.result === user.value?.username) { + if (typed.result === user.value.username) { await os.apiWithDialog('admin/delete-account', { - userId: user.value.id, + userId: props.userId, }); } else { - os.alert({ + await os.alert({ type: 'error', text: 'input not match', }); @@ -544,23 +683,27 @@ async function assignRole() { : period === 'oneMonth' ? Date.now() + (1000 * 60 * 60 * 24 * 30) : null; - await os.apiWithDialog('admin/roles/assign', { roleId, userId: user.value.id, expiresAt }); - refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi('admin/roles/assign', { roleId, userId: props.userId, expiresAt }); + await refreshUser(); + }); } async function unassignRole(role, ev) { - os.popupMenu([{ + await 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.value.id }); - refreshUser(); + await os.promiseDialog(async () => { + await misskeyApi('admin/roles/unassign', { roleId: role.id, userId: props.userId }); + await refreshUser(); + }); }, }], ev.currentTarget ?? ev.target); } -function toggleRoleItem(role) { +function toggleRoleItem(role: Misskey.entities.Role) { if (expandedRoles.value.includes(role.id)) { expandedRoles.value = expandedRoles.value.filter(x => x !== role.id); } else { @@ -569,6 +712,7 @@ function toggleRoleItem(role) { } function createAnnouncement() { + if (!user.value) return; const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), { user: user.value, }, { @@ -577,6 +721,7 @@ function createAnnouncement() { } function editAnnouncement(announcement) { + if (!user.value) return; const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), { user: user.value, announcement, @@ -591,14 +736,6 @@ watch(() => props.userId, () => { immediate: true, }); -watch(user, () => { - misskeyApi('ap/get', { - uri: user.value.uri ?? `${url}/users/${user.value.id}`, - }).then(res => { - ap.value = res; - }); -}); - const headerActions = computed(() => []); const headerTabs = computed(() => isSystem.value ? [{ @@ -782,6 +919,7 @@ definePage(() => ({ cursor: pointer; } +// Sync with instance-info.vue .buttonStrip { margin: calc(var(--MI-margin) / 2 * -1); @@ -789,4 +927,13 @@ definePage(() => ({ margin: calc(var(--MI-margin) / 2); } } + +.rawFolderHeader { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + padding: var(--MI-marginHalf); + gap: var(--MI-marginHalf); +} </style> |