rudeshark.net/packages/client/src/pages/user-info.vue
2023-05-10 14:32:27 -07:00

724 lines
16 KiB
Vue

<template>
<MkStickyContainer>
<template #header
><MkPageHeader
v-model:tab="tab"
:actions="headerActions"
:tabs="headerTabs"
/></template>
<MkSpacer :content-max="600" :margin-min="16" :margin-max="32">
<FormSuspense :p="init">
<div v-if="tab === 'overview'" class="_formRoot">
<div class="_formBlock aeakzknw">
<MkAvatar
class="avatar"
:user="user"
:show-indicator="true"
/>
<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('.')"
class="_formBlock"
>{{ i18n.ts.isSystemAccount }}</MkInfo
>
<div v-if="user.url" class="_formLinksGrid _formBlock">
<FormLink :to="userPage(user)">Profile</FormLink>
<FormLink :to="user.url" :external="true"
>Profile (remote)</FormLink
>
</div>
<FormLink v-else class="_formBlock" :to="userPage(user)"
>Profile</FormLink
>
<FormLink
v-if="user.host"
class="_formBlock"
:to="`/instance-info/${user.host}`"
>{{ i18n.ts.instanceInfo }}</FormLink
>
<div class="_formBlock">
<MkKeyValue
:copy="user.id"
oneline
style="margin: 1em 0"
>
<template #key>ID</template>
<template #value
><span class="_monospace">{{
user.id
}}</span></template
>
</MkKeyValue>
<!-- 要る?
<MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline style="margin: 1em 0;">
<template #key>IP (recent)</template>
<template #value><span class="_monospace">{{ ips[0].ip }}</span></template>
</MkKeyValue>
-->
<MkKeyValue oneline style="margin: 1em 0">
<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 style="margin: 1em 0">
<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 style="margin: 1em 0">
<template #key>{{ i18n.ts.email }}</template>
<template #value
><span class="_monospace">{{
info.email
}}</span></template
>
</MkKeyValue>
</div>
<FormSection>
<template #label>ActivityPub</template>
<div class="_formBlock">
<MkKeyValue
v-if="user.host"
oneline
style="margin: 1em 0"
>
<template #key>{{
i18n.ts.instanceInfo
}}</template>
<template #value
><MkA
:to="`/instance-info/${user.host}`"
class="_link"
>{{ user.host }}
<i
class="ph-caret-right ph-bold ph-lg"
></i></MkA
></template>
</MkKeyValue>
<MkKeyValue v-else oneline style="margin: 1em 0">
<template #key>{{
i18n.ts.instanceInfo
}}</template>
<template #value>(Local user)</template>
</MkKeyValue>
<MkKeyValue oneline style="margin: 1em 0">
<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 style="margin: 1em 0">
<template #key>Type</template>
<template #value
><span class="_monospace">{{
ap.type
}}</span></template
>
</MkKeyValue>
</div>
<FormButton
v-if="user.host != null"
class="_formBlock"
@click="updateRemoteUser"
><i class="ph-arrows-clockwise ph-bold ph-lg"></i>
{{ i18n.ts.updateRemoteUser }}</FormButton
>
<FormFolder class="_formBlock">
<template #label>Raw</template>
<MkObjectView v-if="ap" tall :value="ap">
</MkObjectView>
</FormFolder>
</FormSection>
</div>
<div v-else-if="tab === 'moderation'" class="_formRoot">
<FormSwitch
v-if="
user.host == null &&
$i.isAdmin &&
(moderator || !user.isAdmin)
"
v-model="moderator"
class="_formBlock"
@update:modelValue="toggleModerator"
>{{ i18n.ts.moderator }}</FormSwitch
>
<FormSwitch
v-model="silenced"
class="_formBlock"
@update:modelValue="toggleSilence"
>{{ i18n.ts.silence }}</FormSwitch
>
<FormSwitch
v-model="suspended"
class="_formBlock"
@update:modelValue="toggleSuspend"
>{{ i18n.ts.suspend }}</FormSwitch
>
{{ i18n.ts.reflectMayTakeTime }}
<div class="_formBlock">
<FormButton
v-if="user.host == null && iAmModerator"
inline
@click="resetPassword"
><i class="ph-key ph-bold ph-lg"></i>
{{ i18n.ts.resetPassword }}</FormButton
>
<FormButton
v-if="user.host == null && iAmModerator"
inline
@click="sendModMail"
><i class="ph-warning-diamond ph-bold ph-lg"></i>
{{ i18n.ts.sendModMail }}</FormButton
>
<FormButton
v-if="$i.isAdmin"
inline
danger
@click="deleteAccount"
><i class="ph-user-minus ph-bold ph-lg"></i>
{{ i18n.ts.deleteAccount }}</FormButton
>
</div>
<FormTextarea
v-model="moderationNote"
manual-save
class="_formBlock"
>
<template #label>Moderation note</template>
</FormTextarea>
<FormFolder class="_formBlock">
<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>
</FormFolder>
<FormFolder class="_formBlock">
<template #label>{{ i18n.ts.files }}</template>
<MkFileListForAdmin
:pagination="filesPagination"
view-mode="grid"
/>
</FormFolder>
<FormSection>
<template #label>Drive Capacity Override</template>
<FormInput
v-if="user.host == null"
v-model="driveCapacityOverrideMb"
inline
:manual-save="true"
type="number"
:placeholder="
i18n.t('defaultValueIs', {
value: instance.driveCapacityPerLocalUserMb,
})
"
@update:model-value="applyDriveCapacityOverride"
>
<template #label>{{
i18n.ts.driveCapOverrideLabel
}}</template>
<template #suffix>MB</template>
<template #caption>
{{ i18n.ts.driveCapOverrideCaption }}
</template>
</FormInput>
</FormSection>
</div>
<div v-else-if="tab === 'chart'" class="_formRoot">
<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="_formRoot">
<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, watch } from "vue";
import * as misskey from "calckey-js";
import MkChart from "@/components/MkChart.vue";
import MkObjectView from "@/components/MkObjectView.vue";
import FormTextarea from "@/components/form/textarea.vue";
import FormSwitch from "@/components/form/switch.vue";
import FormLink from "@/components/form/link.vue";
import FormSection from "@/components/form/section.vue";
import FormButton from "@/components/MkButton.vue";
import FormInput from "@/components/form/input.vue";
import FormSplit from "@/components/form/split.vue";
import FormFolder from "@/components/form/folder.vue";
import MkKeyValue from "@/components/MkKeyValue.vue";
import MkSelect from "@/components/form/select.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 number from "@/filters/number";
import bytes from "@/filters/bytes";
import { url } from "@/config";
import { userPage, acct } from "@/filters/user";
import { definePageMetadata } from "@/scripts/page-metadata";
import { i18n } from "@/i18n";
import { iAmAdmin, iAmModerator } from "@/account";
import { instance } from "@/instance";
const props = defineProps<{
userId: string;
}>();
let tab = $ref("overview");
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 driveCapacityOverrideMb: number | null = $ref(0);
let moderationNote = $ref("");
const filesPagination = {
endpoint: "admin/drive/files" as const,
limit: 10,
params: computed(() => ({
userId: props.userId,
})),
};
function createFetcher() {
if (iAmModerator) {
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;
driveCapacityOverrideMb = user.driveCapacityOverrideMb;
moderationNote = info.moderationNote;
watch($$(moderationNote), async () => {
await os.api("admin/update-user-note", {
userId: user.id,
text: moderationNote,
});
await refreshUser();
});
});
} else {
return () =>
os
.api("users/show", {
userId: props.userId,
})
.then((res) => {
user = res;
});
}
}
function refreshUser() {
init = createFetcher();
}
async function updateRemoteUser() {
await os.apiWithDialog("federation/update-remote-user", {
userId: user.id,
});
refreshUser();
}
async function resetPassword() {
const { password } = await os.api("admin/reset-password", {
userId: user.id,
});
os.alert({
type: "success",
text: i18n.t("newPasswordIs", { password }),
});
}
async function toggleSilence(v) {
const confirm = await os.confirm({
type: "warning",
text: v ? i18n.ts.silenceConfirm : i18n.ts.unsilenceConfirm,
});
if (confirm.canceled) {
silenced = !v;
} else {
await os.api(v ? "admin/silence-user" : "admin/unsilence-user", {
userId: user.id,
});
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 = !v;
} else {
await os.api(v ? "admin/suspend-user" : "admin/unsuspend-user", {
userId: user.id,
});
await refreshUser();
}
}
async function toggleModerator(v) {
await os.api(v ? "admin/moderators/add" : "admin/moderators/remove", {
userId: user.id,
});
await refreshUser();
}
async function sendModMail() {
const { canceled, result } = await os.inputParagraph({
title: "Moderation Notice",
});
if (canceled) return;
await os.apiWithDialog("admin/send-mod-mail", {
userId: user.id,
comment: result,
});
}
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 applyDriveCapacityOverride() {
let driveCapOrMb = driveCapacityOverrideMb;
if (driveCapacityOverrideMb && driveCapacityOverrideMb < 0) {
driveCapOrMb = null;
}
try {
await os.apiWithDialog("admin/drive-capacity-override", {
userId: user.id,
overrideMb: driveCapOrMb,
});
await refreshUser();
} catch (err) {
os.alert({
type: "error",
text: err.toString(),
});
}
}
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",
});
}
}
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: "ph-info ph-bold ph-lg",
},
iAmModerator
? {
key: "moderation",
title: i18n.ts.moderation,
icon: "ph-shield ph-bold ph-lg",
}
: null,
{
key: "chart",
title: i18n.ts.charts,
icon: "ph-chart-bar ph-bold ph-lg",
},
{
key: "raw",
title: "Raw",
icon: "ph-code ph-bold ph-lg",
},
].filter((x) => x != null)
);
definePageMetadata(
computed(() => ({
title: user ? acct(user) : i18n.ts.userInfo,
icon: "ph-info ph-bold ph-lg",
}))
);
</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;
}
}
</style>