1
0
mirror of https://git.boykissers.com/pawkey/pawkey-sk.git synced 2025-12-20 04:04:16 +00:00
Files
pawkey-sk/packages/frontend/src/pages/admin-user.vue

887 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
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">
<div v-if="tab === 'overview'" class="_gaps">
<div v-if="user" 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>
<button v-tooltip="i18n.ts.copy" class="_textButton" style="margin-left: 0.5em;" @click="copyToClipboard('@' + acct(user))"><i class="ti ti-copy"></i></button>
</span>
<span class="sub">
<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>
</div>
</div>
<SkBadgeStrip v-if="badges.length > 0" :badges="badges"></SkBadgeStrip>
<MkInfo v-if="isSystem">{{ i18n.ts.isSystemAccount }}</MkInfo>
<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;">
<MkKeyValue v-if="user" :copy="user.id" oneline>
<template #key>{{ i18n.ts.id }}</template>
<template #value><span class="_monospace">{{ user.id }}</span></template>
</MkKeyValue>
<MkKeyValue v-if="user" :copy="'@' + acct(user)" oneline>
<template #key>{{ i18n.ts.username }}</template>
<template #value><span class="_monospace">@{{ acct(user) }}</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>
<MkKeyValue v-if="info" oneline>
<template #key>{{ i18n.ts.totalFollowers }}</template>
<template #value><span class="_monospace"><MkNumber :value="info.followStats.totalFollowers"></MkNumber></span></template>
</MkKeyValue>
<MkKeyValue v-if="info" oneline>
<template #key>{{ i18n.ts.totalFollowing }}</template>
<template #value><span class="_monospace"><MkNumber :value="info.followStats.totalFollowing"></MkNumber></span></template>
</MkKeyValue>
<MkKeyValue v-if="info" oneline>
<template #key>{{ i18n.ts.remoteFollowers }}</template>
<template #value><span class="_monospace"><MkNumber :value="info.followStats.remoteFollowers"></MkNumber></span></template>
</MkKeyValue>
<MkKeyValue v-if="info" oneline>
<template #key>{{ i18n.ts.remoteFollowing }}</template>
<template #value><span class="_monospace"><MkNumber :value="info.followStats.remoteFollowing"></MkNumber></span></template>
</MkKeyValue>
<MkKeyValue v-if="info" oneline>
<template #key>{{ i18n.ts.localFollowers }}</template>
<template #value><span class="_monospace"><MkNumber :value="info.followStats.localFollowers"></MkNumber></span></template>
</MkKeyValue>
<MkKeyValue v-if="info" oneline>
<template #key>{{ i18n.ts.localFollowing }}</template>
<template #value><span class="_monospace"><MkNumber :value="info.followStats.localFollowing"></MkNumber></span></template>
</MkKeyValue>
</div>
</MkFolder>
<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">
<div v-for="policy in Object.keys(info.policies)" :key="policy">
{{ policy }} ... {{ info.policies[policy] }}
</div>
</div>
</MkFolder>
<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>
<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>
</MkFolder>
<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">
<template #label>{{ i18n.ts.moderationNote }}</template>
<template #caption>{{ i18n.ts.moderationNoteDescription }}</template>
</MkTextarea>
</MkFolder>
<FormSection v-if="user?.host">
<template #label>{{ i18n.ts.activityPub }}</template>
<div class="_gaps_m">
<div style="display: flex; flex-direction: column; gap: 1em;">
<MkKeyValue 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 oneline>
<template #key>{{ i18n.ts.updatedAt }}</template>
<template #value><MkTime mode="detail" :time="user.lastFetchedAt"/></template>
</MkKeyValue>
</div>
</div>
</FormSection>
<FormSection v-if="!isSystem && user && iAmModerator">
<div class="_gaps">
<MkSwitch v-model="silenced" @update:modelValue="toggleSilence">{{ i18n.ts.silence }}</MkSwitch>
<MkSwitch v-if="!isSystem" v-model="suspended" @update:modelValue="toggleSuspend">{{ i18n.ts.suspend }}</MkSwitch>
<MkSwitch v-model="rejectQuotes" @update:modelValue="toggleRejectQuotes">{{ user.host == null ? i18n.ts.rejectQuotesLocalUser : i18n.ts.rejectQuotesRemoteUser }}</MkSwitch>
<MkSwitch v-model="markedAsNSFW" @update:modelValue="toggleNSFW">{{ i18n.ts.markAsNSFW }}</MkSwitch>
<MkInput v-model="mandatoryCW" type="text" manualSave @update:modelValue="onMandatoryCWChanged">
<template #label>{{ i18n.ts.mandatoryCW }}</template>
<template #caption>{{ i18n.ts.mandatoryCWDescription }}</template>
</MkInput>
<div :class="$style.buttonStrip">
<MkButton v-if="user.host != null" inline @click="updateRemoteUser"><i class="ph-cloud-arrow-down ph-bold ph-lg"></i> {{ i18n.ts.updateRemoteUser }}</MkButton>
<MkButton v-if="user.host == null" inline accent @click="resetPassword"><i class="ph-password ph-bold ph-lg"></i> {{ i18n.ts.resetPassword }}</MkButton>
<MkButton inline accent @click="unsetUserAvatar"><i class="ph-camera-slash ph-bold ph-lg"></i> {{ i18n.ts.unsetUserAvatar }}</MkButton>
<MkButton inline accent @click="unsetUserBanner"><i class="ph-image-broken ph-bold ph-lg"></i> {{ i18n.ts.unsetUserBanner }}</MkButton>
<MkButton inline danger @click="deleteAllFiles"><i class="ph-trash ph-bold ph-lg"></i> {{ i18n.ts.deleteAllFiles }}</MkButton>
<MkButton v-if="iAmAdmin" inline danger @click="deleteAccount"><i class="ph-skull ph-bold ph-lg"></i> {{ i18n.ts.deleteAccount }}</MkButton>
</div>
</div>
</FormSection>
</div>
<div v-else-if="tab === 'roles'" class="_gaps">
<MkButton primary rounded @click="assignRole"><i class="ti ti-plus"></i> {{ i18n.ts.assign }}</MkButton>
<div v-for="role in info.roles" :key="role.id">
<div :class="$style.roleItemMain">
<MkRolePreview :class="$style.role" :role="role" :forModeration="true"/>
<button class="_button" @click="toggleRoleItem(role)">
<i v-if="!expandedRoles.includes(role.id)" class="ti ti-chevron-down"></i>
<i v-if="expandedRoles.includes(role.id)" class="ti ti-chevron-left"></i>
</button>
<button v-if="role.target === 'manual' || info.roleAssigns.some(a => a.roleId === role.id)" 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">
<template v-if="info.roleAssigns.some(a => a.roleId === role.id)">
<div>{{ i18n.ts.roleAssigned }}: <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">{{ i18n.ts.rolePeriod }}: {{ new Date(info.roleAssigns.find(a => a.roleId === role.id).expiresAt).toLocaleString() }}</div>
<div v-else>{{ i18n.ts.rolePeriod }}: {{ i18n.ts.indefinitely }}</div>
</template>
<template v-else>
<div>{{ i18n.ts.roleAssigned }}: {{ i18n.ts.roleAutomatic }}</div>
</template>
</div>
</div>
</div>
<div v-else-if="tab === 'announcements'" class="_gaps">
<MkButton primary rounded @click="createAnnouncement"><i class="ti ti-plus"></i> {{ i18n.ts._announcement.new }}</MkButton>
<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>
<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(--MI_THEME-warn);"></i>
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--MI_THEME-error);"></i>
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--MI_THEME-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.tsx.recentNHours({ n: 90 }) }}</div>
<MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart>
<div class="label">{{ i18n.tsx.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>
</div>
</PageWithHeader>
</template>
<script lang="ts" setup>
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 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.js';
import { misskeyApi } from '@/utility/misskey-api.js';
import { acct } from '@/filters/user.js';
import { definePage } from '@/page.js';
import { i18n } from '@/i18n.js';
import { iAmAdmin, $i, iAmModerator } from '@/i.js';
import MkRolePreview from '@/components/MkRolePreview.vue';
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;
}>(), {
initialTab: 'overview',
userHint: undefined,
infoHint: undefined,
ipsHint: undefined,
});
const tab = ref(props.initialTab);
const chartSrc = ref('per-user-notes');
const user = ref<null | Misskey.entities.UserDetailed>();
const init = ref<ReturnType<typeof createFetcher>>();
const info = ref<Misskey.entities.AdminShowUserResponse | null>(null);
const ips = ref<Misskey.entities.AdminGetUserIpsResponse | null>(null);
const ap = ref<Misskey.entities.ApGetResponse | null>(null);
const moderator = ref(false);
const silenced = ref(false);
const approved = ref(false);
const suspended = ref(false);
const rejectQuotes = ref(false);
const markedAsNSFW = ref(false);
const moderationNote = ref('');
const mandatoryCW = ref<string | null>(null);
const isSystem = computed(() => info.value?.isSystem ?? false);
const filesPagination = {
endpoint: 'admin/drive/files' as const,
limit: 10,
params: computed(() => ({
userId: props.userId,
})),
};
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 = {
endpoint: 'admin/announcements/list' as const,
limit: 10,
params: computed(() => ({
userId: props.userId,
status: announcementsStatus.value,
})),
};
const expandedRoles = ref([]);
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 ? misskeyApi('ap/get', {
uri: `${url}/users/${props.userId}`,
}).catch(() => null) : null],
).then(([_user, _info, _ips, _ap]) => {
user.value = _user;
info.value = _info;
ips.value = _ips;
ap.value = _ap;
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;
});
}
async function refreshUser() {
// Not a typo - createFetcher() returns a function()
await createFetcher(false)();
}
async function onMandatoryCWChanged(value: string) {
await os.promiseDialog(async () => {
await misskeyApi('admin/cw-user', { userId: props.userId, cw: value });
await refreshUser();
});
}
async function onModerationNoteChanged(value: string) {
await os.promiseDialog(async () => {
await misskeyApi('admin/update-user-note', { userId: props.userId, text: value });
refreshUser();
});
}
async function updateRemoteUser() {
await os.promiseDialog(async () => {
await misskeyApi('federation/update-remote-user', { userId: props.userId });
refreshUser();
});
}
async function resetPassword() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.resetPasswordConfirm,
});
if (confirm.canceled) {
return;
} else {
const { password } = await misskeyApi('admin/reset-password', {
userId: user.value.id,
});
await os.alert({
type: 'success',
text: i18n.tsx.newPasswordIs({ password }),
});
}
}
async function toggleNSFW(v) {
const confirm = await os.confirm({
type: 'warning',
text: v ? i18n.ts.nsfwConfirm : i18n.ts.unNsfwConfirm,
});
if (confirm.canceled) {
markedAsNSFW.value = !v;
} else {
await misskeyApi(v ? 'admin/nsfw-user' : 'admin/unnsfw-user', { userId: props.userId });
await refreshUser();
}
}
async function toggleSilence(v) {
const confirm = await os.confirm({
type: 'warning',
text: v ? i18n.ts.silenceConfirm : i18n.ts.unsilenceConfirm,
});
if (confirm.canceled) {
silenced.value = !v;
} else {
await os.promiseDialog(async () => {
await misskeyApi(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: props.userId });
await refreshUser();
});
}
}
async function toggleSuspend(v) {
const confirm = await os.confirm({
type: 'warning',
text: v ? i18n.ts.suspendConfirm : i18n.ts.unsuspendConfirm,
});
if (confirm.canceled) {
suspended.value = !v;
} else {
await os.promiseDialog(async () => {
await misskeyApi(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: props.userId });
await refreshUser();
});
}
}
async function toggleRejectQuotes(v: boolean): Promise<void> {
const confirm = await os.confirm({
type: 'warning',
text: v ? i18n.ts.rejectQuotesConfirm : i18n.ts.allowQuotesConfirm,
});
if (confirm.canceled) {
rejectQuotes.value = !v;
} else {
await os.promiseDialog(async () => {
await misskeyApi('admin/reject-quotes', {
userId: props.userId,
rejectQuotes: v,
});
await refreshUser();
});
}
}
async function unsetUserAvatar() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unsetUserAvatarConfirm,
});
if (confirm.canceled) return;
await os.promiseDialog(async () => {
await misskeyApi('admin/unset-user-avatar', { userId: props.userId });
await refreshUser();
});
}
async function unsetUserBanner() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.unsetUserBannerConfirm,
});
if (confirm.canceled) return;
await os.promiseDialog(async () => {
await misskeyApi('admin/unset-user-banner', { userId: props.userId });
await refreshUser();
});
}
async function deleteAllFiles() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.deleteAllFilesConfirm,
});
if (confirm.canceled) return;
await os.promiseDialog(async () => {
await misskeyApi('admin/delete-all-files-of-a-user', { userId: props.userId });
await refreshUser();
});
}
async function deleteAccount() {
const confirm = await os.confirm({
type: 'warning',
text: i18n.ts.deleteThisAccountConfirm,
});
if (confirm.canceled) return;
const typed = await os.inputText({
text: i18n.tsx.typeToConfirm({ x: user.value?.username }),
});
if (typed.canceled) return;
if (typed.result === user.value?.username) {
await os.apiWithDialog('admin/delete-account', {
userId: user.value.id,
});
} else {
await os.alert({
type: 'error',
text: 'input not match',
});
}
}
async function assignRole() {
const roles = await misskeyApi('admin/roles/list').then(it => it.filter(r => r.target === 'manual'));
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 + ': ' + roles.find(r => r.id === roleId)!.name,
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.promiseDialog(async () => {
await misskeyApi('admin/roles/assign', { roleId, userId: props.userId, expiresAt });
await refreshUser();
});
}
async function unassignRole(role, ev) {
await os.popupMenu([{
text: i18n.ts.unassign,
icon: 'ti ti-x',
danger: true,
action: async () => {
await os.promiseDialog(async () => {
await misskeyApi('admin/roles/unassign', { roleId: role.id, userId: props.userId });
await refreshUser();
});
},
}], ev.currentTarget ?? ev.target);
}
function toggleRoleItem(role) {
if (expandedRoles.value.includes(role.id)) {
expandedRoles.value = expandedRoles.value.filter(x => x !== role.id);
} else {
expandedRoles.value.push(role.id);
}
}
function createAnnouncement() {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), {
user: user.value,
}, {
closed: () => dispose(),
});
}
function editAnnouncement(announcement) {
const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkUserAnnouncementEditDialog.vue')), {
user: user.value,
announcement,
}, {
closed: () => dispose(),
});
}
watch(() => props.userId, () => {
init.value = createFetcher();
}, {
immediate: true,
});
const headerActions = computed(() => []);
const headerTabs = computed(() => isSystem.value ? [{
key: 'overview',
title: i18n.ts.overview,
icon: 'ti ti-info-circle',
}, {
key: 'raw',
title: 'Raw',
icon: 'ti ti-code',
}] : [{
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',
}]);
definePage(() => ({
title: user.value ? acct(user.value) : 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: var(--MI-radius-sm);
padding: 2px 6px;
font-size: 85%;
}
> .suspended {
color: var(--MI_THEME-error);
border-color: var(--MI_THEME-error);
}
> .silenced {
color: var(--MI_THEME-warn);
border-color: var(--MI_THEME-warn);
}
> .moderator {
color: var(--MI_THEME-success);
border-color: var(--MI_THEME-success);
}
}
}
}
.cmhjzshm {
> .selects {
display: flex;
margin: 0 0 16px 0;
}
> .charts {
> .label {
margin-bottom: 12px;
font-weight: bold;
}
}
}
.casdwq {
.silenced {
color: var(--MI_THEME-warn);
border-color: var(--MI_THEME-warn);
}
.moderator {
color: var(--MI_THEME-success);
border-color: var(--MI_THEME-success);
}
}
</style>
<style lang="scss" module>
.ip {
display: flex;
word-break: break-all;
> :global(.date) {
opacity: 0.7;
}
> :global(.ip) {
margin-left: auto;
}
}
.roleItemMain {
display: flex;
}
.role {
flex: 1;
min-width: 0;
margin-right: 8px;
}
.roleItemSub {
padding: 6px 12px;
font-size: 85%;
color: color(from var(--MI_THEME-fg) srgb r g b / 0.75);
}
.roleUnassign {
width: 32px;
height: 32px;
margin-left: 8px;
align-self: center;
}
.announcementItem {
display: flex;
padding: 8px 12px;
border-radius: var(--MI-radius-sm);
cursor: pointer;
}
// Sync with instance-info.vue
.buttonStrip {
margin: calc(var(--MI-margin) / 2 * -1);
>* {
margin: calc(var(--MI-margin) / 2);
}
}
</style>