From 63225ed0fd712873a434e9e3600650a46b8653d4 Mon Sep 17 00:00:00 2001
From: Satsuki Yanagi <17376330+u1-liquid@users.noreply.github.com>
Date: Mon, 13 Apr 2020 23:27:12 +0900
Subject: [PATCH] =?UTF-8?q?=E3=83=A2=E3=83=87=E3=83=AC=E3=83=BC=E3=82=B7?=
 =?UTF-8?q?=E3=83=A7=E3=83=B3=E5=91=A8=E3=82=8A=E3=81=AEv11=E3=81=AE?=
 =?UTF-8?q?=E6=A9=9F=E8=83=BD=E5=BE=A9=E5=85=83=20(#6249)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* モデレーション周りのv11の機能復元

* i18n

* wip

* wip

Co-authored-by: syuilo <syuilotan@yahoo.co.jp>
---
 locales/ja-JP.yml                             |   9 +
 src/client/components/note.vue                |   6 +-
 src/client/components/user-menu.vue           |  27 ++-
 .../components/user-moderate-dialog.vue       | 105 ---------
 .../pages/instance/federation.instance.vue    |  41 +++-
 src/client/pages/instance/users.user.vue      | 209 ++++++++++++++++++
 src/client/pages/instance/users.vue           | 144 +++++++++---
 src/client/pages/user/index.photos.vue        |   2 +-
 src/client/pages/user/index.vue               |  14 +-
 src/client/router.ts                          |   1 +
 10 files changed, 409 insertions(+), 149 deletions(-)
 delete mode 100644 src/client/components/user-moderate-dialog.vue
 create mode 100644 src/client/pages/instance/users.user.vue

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 848bf4bb43..1812a2660c 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -265,6 +265,7 @@ watch: "ウォッチ"
 unwatch: "ウォッチ解除"
 accept: "許可"
 reject: "拒否"
+normal: "正常"
 instanceName: "インスタンス名"
 instanceDescription: "インスタンスの紹介"
 maintainerName: "管理者の名前"
@@ -319,6 +320,7 @@ notesAndReplies: "投稿と返信"
 withFiles: "ファイル付き"
 silence: "サイレンス"
 silenceConfirm: "サイレンスしますか?"
+unsilence: "サイレンス解除"
 unsilenceConfirm: "サイレンス解除しますか?"
 popularUsers: "人気のユーザー"
 recentlyUpdatedUsers: "最近投稿したユーザー"
@@ -483,6 +485,13 @@ scratchpadDescription: "スクラッチパッドは、AiScriptの実験環境を
 output: "出力"
 script: "スクリプト"
 disablePagesScript: "Pagesのスクリプトを無効にする"
+updateRemoteUser: "リモートユーザー情報の更新"
+deleteAllFiles: "すべてのファイルを削除"
+deleteAllFilesConfirm: "すべてのファイルを削除しますか?"
+removeAllFollowing: "フォローを全解除"
+removeAllFollowingDescription: "{host}からのフォローをすべて解除します。そのインスタンスがもう存在しなくなった場合などに実行してください。"
+userSuspended: "このユーザーは凍結されています。"
+userSilenced: "このユーザーはサイレンスされています。"
 
 _theme:
   explore: "テーマを探す"
diff --git a/src/client/components/note.vue b/src/client/components/note.vue
index 07011ba50f..18d5cc34ba 100644
--- a/src/client/components/note.vue
+++ b/src/client/components/note.vue
@@ -561,13 +561,13 @@ export default Vue.extend({
 					}]
 					: []
 				),
-				...(this.appearNote.userId == this.$store.state.i.id ? [
+				...(this.appearNote.userId == this.$store.state.i.id || this.$store.state.i.isModerator || this.$store.state.i.isAdmin ? [
 					null,
-					{
+					this.appearNote.userId == this.$store.state.i.id ? {
 						icon: faEdit,
 						text: this.$t('deleteAndEdit'),
 						action: this.delEdit
-					},
+					} : undefined,
 					{
 						icon: faTrashAlt,
 						text: this.$t('delete'),
diff --git a/src/client/components/user-menu.vue b/src/client/components/user-menu.vue
index b0139380ef..a2275197d8 100644
--- a/src/client/components/user-menu.vue
+++ b/src/client/components/user-menu.vue
@@ -4,7 +4,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments, faUsers } from '@fortawesome/free-solid-svg-icons';
+import { faAt, faListUl, faEye, faEyeSlash, faBan, faPencilAlt, faComments, faUsers, faMicrophoneSlash } from '@fortawesome/free-solid-svg-icons';
 import { faSnowflake, faEnvelope } from '@fortawesome/free-regular-svg-icons';
 import i18n from '../i18n';
 import XMenu from './menu.vue';
@@ -60,8 +60,12 @@ export default Vue.extend({
 				action: this.toggleBlock
 			}]);
 
-			if (this.$store.state.i.isAdmin) {
+			if (this.$store.getters.isSignedIn && (this.$store.state.i.isAdmin || this.$store.state.i.isModerator)) {
 				menu = menu.concat([null, {
+					icon: faMicrophoneSlash,
+					text: this.user.isSilenced ? this.$t('unsilence') : this.$t('silence'),
+					action: this.toggleSilence
+				}, {
 					icon: faSnowflake,
 					text: this.user.isSuspended ? this.$t('unsuspend') : this.$t('suspend'),
 					action: this.toggleSuspend
@@ -194,6 +198,25 @@ export default Vue.extend({
 			});
 		},
 
+		async toggleSilence() {
+			if (!await this.getConfirmed(this.$t(this.user.isSilenced ? 'unsilenceConfirm' : 'silenceConfirm'))) return;
+
+			this.$root.api(this.user.isSilenced ? 'admin/unsilence-user' : 'admin/silence-user', {
+				userId: this.user.id
+			}).then(() => {
+				this.user.isSilenced = !this.user.isSilenced;
+				this.$root.dialog({
+					type: 'success',
+					iconOnly: true, autoClose: true
+				});
+			}, e => {
+				this.$root.dialog({
+					type: 'error',
+					text: e
+				});
+			});
+		},
+
 		async toggleSuspend() {
 			if (!await this.getConfirmed(this.$t(this.user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return;
 
diff --git a/src/client/components/user-moderate-dialog.vue b/src/client/components/user-moderate-dialog.vue
deleted file mode 100644
index 65e1e30c2e..0000000000
--- a/src/client/components/user-moderate-dialog.vue
+++ /dev/null
@@ -1,105 +0,0 @@
-<template>
-<x-window @closed="() => { $emit('closed'); destroyDom(); }" :avatar="user">
-	<template #header><mk-user-name :user="user"/></template>
-	<div class="vrcsvlkm">
-		<mk-button @click="resetPassword()" primary>{{ $t('resetPassword') }}</mk-button>
-		<mk-switch v-if="$store.state.i.isAdmin && (this.moderator || !user.isAdmin)" @change="toggleModerator()" v-model="moderator">{{ $t('moderator') }}</mk-switch>
-		<mk-switch @change="toggleSilence()" v-model="silenced">{{ $t('silence') }}</mk-switch>
-		<mk-switch @change="toggleSuspend()" v-model="suspended">{{ $t('suspend') }}</mk-switch>
-	</div>
-</x-window>
-</template>
-
-<script lang="ts">
-import Vue from 'vue';
-import i18n from '../i18n';
-import MkButton from './ui/button.vue';
-import MkSwitch from './ui/switch.vue';
-import XWindow from './window.vue';
-
-export default Vue.extend({
-	i18n,
-
-	components: {
-		MkButton,
-		MkSwitch,
-		XWindow,
-	},
-
-	props: {
-		user: {
-			type: Object,
-			required: true
-		}
-	},
-
-	data() {
-		return {
-			moderator: this.user.isModerator,
-			silenced: this.user.isSilenced,
-			suspended: this.user.isSuspended,
-		};
-	},
-
-	methods: {
-		async resetPassword() {
-			const dialog = this.$root.dialog({
-				type: 'waiting',
-				iconOnly: true
-			});
-
-			this.$root.api('admin/reset-password', {
-				userId: this.user.id,
-			}).then(({ password }) => {
-				this.$root.dialog({
-					type: 'success',
-					text: this.$t('newPasswordIs', { password })
-				});
-			}).catch(e => {
-				this.$root.dialog({
-					type: 'error',
-					text: e
-				});
-			}).finally(() => {
-				dialog.close();
-			});
-		},
-
-		async toggleSilence() {
-			const confirm = await this.$root.dialog({
-				type: 'warning',
-				showCancelButton: true,
-				text: this.silenced ? this.$t('silenceConfirm') : this.$t('unsilenceConfirm'),
-			});
-			if (confirm.canceled) {
-				this.silenced = !this.silenced;
-			} else {
-				this.$root.api(this.silenced ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id });
-			}
-		},
-
-		async toggleSuspend() {
-			const confirm = await this.$root.dialog({
-				type: 'warning',
-				showCancelButton: true,
-				text: this.suspended ? this.$t('suspendConfirm') : this.$t('unsuspendConfirm'),
-			});
-			if (confirm.canceled) {
-				this.suspended = !this.suspended;
-			} else {
-				this.$root.api(this.suspended ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id });
-			}
-		},
-
-		async toggleModerator() {
-			this.$root.api(this.moderator ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id });
-		}
-	}
-});
-</script>
-
-<style lang="scss" scoped>
-.vrcsvlkm {
-
-}
-</style>
diff --git a/src/client/pages/instance/federation.instance.vue b/src/client/pages/instance/federation.instance.vue
index b86f52809e..08f4d1b4fb 100644
--- a/src/client/pages/instance/federation.instance.vue
+++ b/src/client/pages/instance/federation.instance.vue
@@ -99,10 +99,19 @@
 			<span class="label">{{ $t('operations') }}</span>
 			<mk-switch v-model="isSuspended" class="switch">{{ $t('stopActivityDelivery') }}</mk-switch>
 			<mk-switch :value="isBlocked" class="switch" @change="changeBlock">{{ $t('blockThisInstance') }}</mk-switch>
+			<details>
+				<summary>{{ $t('deleteAllFiles') }}</summary>
+				<mk-button @click="deleteAllFiles()" style="margin: 0.5em 0 0.5em 0;"><fa :icon="faTrashAlt"/> {{ $t('deleteAllFiles') }}</mk-button>
+			</details>
+			<details>
+				<summary>{{ $t('removeAllFollowing') }}</summary>
+				<mk-button @click="removeAllFollowing()" style="margin: 0.5em 0 0.5em 0;"><fa :icon="faMinusCircle"/> {{ $t('removeAllFollowing') }}</mk-button>
+				<mk-info warn>{{ $t('removeAllFollowingDescription', { host: instance.host }) }}</mk-info>
+			</details>
 		</div>
 		<details class="metadata">
 			<summary class="label">{{ $t('metadata') }}</summary>
-			<pre><code>{{ JSON.stringify(instance.metadata, null, 2) }}</code></pre>
+			<pre><code>{{ JSON.stringify(instance, null, 2) }}</code></pre>
 		</details>
 	</div>
 </x-window>
@@ -112,11 +121,13 @@
 import Vue from 'vue';
 import Chart from 'chart.js';
 import i18n from '../../i18n';
-import { faTimes, faCrosshairs, faCloudDownloadAlt, faCloudUploadAlt, faUsers, faPencilAlt, faFileImage, faDatabase, faTrafficLight, faLongArrowAltUp, faLongArrowAltDown } from '@fortawesome/free-solid-svg-icons';
+import { faTimes, faCrosshairs, faCloudDownloadAlt, faCloudUploadAlt, faUsers, faPencilAlt, faFileImage, faDatabase, faTrafficLight, faLongArrowAltUp, faLongArrowAltDown, faMinusCircle, faTrashAlt } from '@fortawesome/free-solid-svg-icons';
 import XWindow from '../../components/window.vue';
 import MkUsersDialog from '../../components/users-dialog.vue';
 import MkSelect from '../../components/ui/select.vue';
+import MkButton from '../../components/ui/button.vue';
 import MkSwitch from '../../components/ui/switch.vue';
+import MkInfo from '../../components/ui/info.vue';
 
 const chartLimit = 90;
 const sum = (...arr) => arr.reduce((r, a) => r.map((b, i) => a[i] + b));
@@ -135,7 +146,9 @@ export default Vue.extend({
 	components: {
 		XWindow,
 		MkSelect,
+		MkButton,
 		MkSwitch,
+		MkInfo,
 	},
 
 	props: {
@@ -153,7 +166,7 @@ export default Vue.extend({
 			chartInstance: null,
 			chartSrc: 'requests',
 			chartSpan: 'hour',
-			faTimes, faCrosshairs, faCloudDownloadAlt, faCloudUploadAlt, faUsers, faPencilAlt, faFileImage, faDatabase, faTrafficLight, faLongArrowAltUp, faLongArrowAltDown
+			faTimes, faCrosshairs, faCloudDownloadAlt, faCloudUploadAlt, faUsers, faPencilAlt, faFileImage, faDatabase, faTrafficLight, faLongArrowAltUp, faLongArrowAltDown, faMinusCircle, faTrashAlt
 		};
 	},
 
@@ -239,6 +252,28 @@ export default Vue.extend({
 			this.chartSrc = src;
 		},
 
+		removeAllFollowing() {
+			this.$root.api('admin/federation/remove-all-following', {
+				host: this.instance.host
+			}).then(() => {
+				this.$root.dialog({
+					type: 'success',
+					iconOnly: true, autoClose: true
+				});
+			});
+		},
+
+		deleteAllFiles() {
+			this.$root.api('admin/federation/delete-all-files', {
+				host: this.instance.host
+			}).then(() => {
+				this.$root.dialog({
+					type: 'success',
+					iconOnly: true, autoClose: true
+				});
+			});
+		},
+
 		renderChart() {
 			if (this.chartInstance) {
 				this.chartInstance.destroy();
diff --git a/src/client/pages/instance/users.user.vue b/src/client/pages/instance/users.user.vue
new file mode 100644
index 0000000000..1fb064f7f0
--- /dev/null
+++ b/src/client/pages/instance/users.user.vue
@@ -0,0 +1,209 @@
+<template>
+<div class="vrcsvlkm" v-if="user && info">
+	<portal to="title" v-if="user"><mk-user-name :user="user" :nowrap="false" class="name"/></portal>
+	<portal to="avatar" v-if="user"><mk-avatar class="avatar" :user="user" :disable-preview="true"/></portal>
+
+	<section class="_card">
+		<div class="_title">
+			<mk-avatar class="avatar" :user="user"/>
+			<mk-user-name class="name" :user="user"/>
+			<span class="acct">@{{ user | acct }}</span>
+			<span class="staff" v-if="user.isAdmin"><fa :icon="faBookmark"/></span>
+			<span class="staff" v-if="user.isModerator"><fa :icon="farBookmark"/></span>
+			<span class="punished" v-if="user.isSilenced"><fa :icon="faMicrophoneSlash"/></span>
+			<span class="punished" v-if="user.isSuspended"><fa :icon="faSnowflake"/></span>
+		</div>
+		<div class="_content actions">
+			<div style="flex: 1; padding-left: 1em;">
+				<mk-switch v-if="user.host == null && $store.state.i.isAdmin && (this.moderator || !user.isAdmin)" @change="toggleModerator()" v-model="moderator">{{ $t('moderator') }}</mk-switch>
+				<mk-switch @change="toggleSilence()" v-model="silenced">{{ $t('silence') }}</mk-switch>
+				<mk-switch @change="toggleSuspend()" v-model="suspended">{{ $t('suspend') }}</mk-switch>
+			</div>
+			<div style="flex: 1; padding-left: 1em;">
+				<mk-button @click="openProfile"><fa :icon="faExternalLinkSquareAlt"/> {{ $t('profile')}}</mk-button>
+				<mk-button v-if="user.host != null" @click="updateRemoteUser"><fa :icon="faSync"/> {{ $t('updateRemoteUser') }}</mk-button>
+				<mk-button @click="resetPassword"><fa :icon="faKey"/> {{ $t('resetPassword') }}</mk-button>
+				<mk-button @click="deleteAllFiles"><fa :icon="faTrashAlt"/> {{ $t('deleteAllFiles') }}</mk-button>
+			</div>
+		</div>
+		<div class="_content rawdata">
+			<pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre>
+		</div>
+	</section>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faTimes, faBookmark, faKey, faSync, faMicrophoneSlash, faExternalLinkSquareAlt } from '@fortawesome/free-solid-svg-icons';
+import { faSnowflake, faTrashAlt, faBookmark as farBookmark  } from '@fortawesome/free-regular-svg-icons';
+import MkButton from '../../components/ui/button.vue';
+import MkSwitch from '../../components/ui/switch.vue';
+import i18n from '../../i18n';
+import Progress from '../../scripts/loading';
+
+export default Vue.extend({
+	i18n,
+
+	components: {
+		MkButton,
+		MkSwitch,
+	},
+
+	data() {
+		return {
+			user: null,
+			info: null,
+			moderator: false,
+			silenced: false,
+			suspended: false,
+			faTimes, faBookmark, farBookmark, faKey, faSync, faMicrophoneSlash, faSnowflake, faTrashAlt, faExternalLinkSquareAlt
+		};
+	},
+
+	watch: {
+		$route: 'fetch'
+	},
+
+	created() {
+		this.fetch();
+	},
+
+	methods: {
+		async fetch() {
+			Progress.start();
+			this.user = await this.$root.api('users/show', { userId: this.$route.params.user });
+			this.info = await this.$root.api('admin/show-user', { userId: this.$route.params.user });
+			this.moderator = this.info.isModerator;
+			this.silenced = this.info.isSilenced;
+			this.suspended = this.info.isSuspended;
+			Progress.done();
+		},
+
+		/** 処理対象ユーザーの情報を更新する */
+		async refreshUser() {
+			this.user = await this.$root.api('users/show', { userId: this.user.id });
+			this.info = await this.$root.api('admin/show-user', { userId: this.user.id });
+		},
+
+		openProfile() {
+			window.open(Vue.filter('userPage')(this.user, null, true), '_blank');
+		},
+
+		async updateRemoteUser() {
+			await this.$root.api('admin/update-remote-user', { userId: this.user.id }).then(res => {
+				this.$root.dialog({
+					type: 'success',
+					iconOnly: true, autoClose: true
+				});
+			});
+			await this.refreshUser();
+		},
+
+		async resetPassword() {
+			const dialog = this.$root.dialog({
+				type: 'waiting',
+				iconOnly: true
+			});
+
+			this.$root.api('admin/reset-password', {
+				userId: this.user.id,
+			}).then(({ password }) => {
+				this.$root.dialog({
+					type: 'success',
+					text: this.$t('newPasswordIs', { password })
+				});
+			}).catch(e => {
+				this.$root.dialog({
+					type: 'error',
+					text: e
+				});
+			}).finally(() => {
+				dialog.close();
+			});
+		},
+
+		async toggleSilence() {
+			const confirm = await this.$root.dialog({
+				type: 'warning',
+				showCancelButton: true,
+				text: this.silenced ? this.$t('silenceConfirm') : this.$t('unsilenceConfirm'),
+			});
+			if (confirm.canceled) {
+				this.silenced = !this.silenced;
+			} else {
+				await this.$root.api(this.silenced ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id });
+				await this.refreshUser();
+			}
+		},
+
+		async toggleSuspend() {
+			const confirm = await this.$root.dialog({
+				type: 'warning',
+				showCancelButton: true,
+				text: this.suspended ? this.$t('suspendConfirm') : this.$t('unsuspendConfirm'),
+			});
+			if (confirm.canceled) {
+				this.suspended = !this.suspended;
+			} else {
+				await this.$root.api(this.suspended ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id });
+				await this.refreshUser();
+			}
+		},
+
+		async toggleModerator() {
+			await this.$root.api(this.moderator ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id });
+			await this.refreshUser();
+		},
+
+		async deleteAllFiles() {
+			const confirm = await this.$root.dialog({
+				type: 'warning',
+				showCancelButton: true,
+				text: this.$t('deleteAllFilesConfirm'),
+			});
+			if (confirm.canceled) return;
+			const process = async () => {
+				await this.$root.api('admin/delete-all-files-of-a-user', { userId: this.user.id });
+				this.$root.dialog({
+					type: 'success',
+					iconOnly: true, autoClose: true
+				});
+			};
+			await process().catch(e => {
+				this.$root.dialog({
+					type: 'error',
+					text: e.toString()
+				});
+			});
+			await this.refreshUser();
+		},
+	}
+});
+</script>
+
+<style lang="scss" scoped>
+.vrcsvlkm {
+	display: flex;
+	flex-direction: column;
+
+	> ._card {
+		> .actions {
+			display: flex;
+			box-sizing: border-box;
+			text-align: left;
+			align-items: center;
+			margin-top: 16px;
+			margin-bottom: 16px;
+		}
+
+		> .rawdata {
+			> pre > code {
+				display: block;
+				width: 100%;
+				height: 100%;
+			}
+		}
+	}
+}
+</style>
diff --git a/src/client/pages/instance/users.vue b/src/client/pages/instance/users.vue
index db9e625e4c..b209ab68cf 100644
--- a/src/client/pages/instance/users.vue
+++ b/src/client/pages/instance/users.vue
@@ -12,19 +12,65 @@
 			<mk-button @click="showUser()" primary><fa :icon="faSearch"/> {{ $t('lookup') }}</mk-button>
 		</div>
 		<div class="_footer">
-			<mk-button inline primary @click="search()"><fa :icon="faSearch"/> {{ $t('search') }}</mk-button>
+			<mk-button inline primary @click="searchUser()"><fa :icon="faSearch"/> {{ $t('search') }}</mk-button>
 		</div>
 	</section>
 
 	<section class="_card users">
 		<div class="_title"><fa :icon="faUsers"/> {{ $t('users') }}</div>
+		<div class="_content">
+			<div class="inputs" style="display: flex;">
+				<mk-select v-model="sort" style="margin: 0; flex: 1;">
+					<template #label>{{ $t('sort') }}</template>
+					<option value="-createdAt">{{ $t('registeredDate') }} ({{ $t('ascendingOrder') }})</option>
+					<option value="+createdAt">{{ $t('registeredDate') }} ({{ $t('descendingOrder') }})</option>
+					<option value="-updatedAt">{{ $t('lastUsed') }} ({{ $t('ascendingOrder') }})</option>
+					<option value="+updatedAt">{{ $t('lastUsed') }} ({{ $t('descendingOrder') }})</option>
+				</mk-select>
+				<mk-select v-model="state" style="margin: 0; flex: 1;">
+					<template #label>{{ $t('state') }}</template>
+					<option value="all">{{ $t('all') }}</option>
+					<option value="available">{{ $t('normal') }}</option>
+					<option value="admin">{{ $t('administrator') }}</option>
+					<option value="moderator">{{ $t('moderator') }}</option>
+					<option value="silenced">{{ $t('silence') }}</option>
+					<option value="suspended">{{ $t('suspend') }}</option>
+				</mk-select>
+				<mk-select v-model="origin" style="margin: 0; flex: 1;">
+					<template #label>{{ $t('instance') }}</template>
+					<option value="combined">{{ $t('all') }}</option>
+					<option value="local">{{ $t('local') }}</option>
+					<option value="remote">{{ $t('remote') }}</option>
+				</mk-select>
+			</div>
+			<div class="inputs" style="display: flex; padding-top: 1.2em;">
+				<mk-input v-model="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false" @input="$refs.users.reload()">
+					<span>{{ $t('username') }}</span>
+				</mk-input>
+				<mk-input v-model="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" @input="$refs.users.reload()" :disabled="pagination.params().origin === 'local'">
+					<span>{{ $t('host') }}</span>
+				</mk-input>
+			</div>
+		</div>
 		<div class="_content _list">
 			<mk-pagination :pagination="pagination" #default="{items}" class="users" ref="users" :auto-margin="false">
 				<button class="user _button _listItem" v-for="(user, i) in items" :key="user.id" @click="show(user)">
-					<mk-avatar :user="user" class="avatar"/>
+					<mk-avatar class="avatar" :user="user" :disable-link="true"/>
 					<div class="body">
-						<mk-user-name :user="user" class="name"/>
-						<mk-acct :user="user" class="acct"/>
+						<header>
+							<mk-user-name class="name" :user="user"/>
+							<span class="acct">@{{ user | acct }}</span>
+							<span class="staff" v-if="user.isAdmin"><fa :icon="faBookmark"/></span>
+							<span class="staff" v-if="user.isModerator"><fa :icon="farBookmark"/></span>
+							<span class="punished" v-if="user.isSilenced"><fa :icon="faMicrophoneSlash"/></span>
+							<span class="punished" v-if="user.isSuspended"><fa :icon="faSnowflake"/></span>
+						</header>
+						<div>
+							<span>{{ $t('lastUsed') }}: <mk-time :time="user.updatedAt" mode="detail"/></span>
+						</div>
+						<div>
+							<span>{{ $t('registeredDate') }}: <mk-time :time="user.createdAt" mode="detail"/></span>
+						</div>
 					</div>
 				</button>
 			</mk-pagination>
@@ -38,12 +84,13 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import { faPlus, faUsers, faSearch } from '@fortawesome/free-solid-svg-icons';
+import { faPlus, faUsers, faSearch, faBookmark, faMicrophoneSlash } from '@fortawesome/free-solid-svg-icons';
+import { faSnowflake, faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons';
 import parseAcct from '../../../misc/acct/parse';
 import MkButton from '../../components/ui/button.vue';
 import MkInput from '../../components/ui/input.vue';
+import MkSelect from '../../components/ui/select.vue';
 import MkPagination from '../../components/ui/pagination.vue';
-import MkUserModerateDialog from '../../components/user-moderate-dialog.vue';
 import MkUserSelect from '../../components/user-select.vue';
 
 export default Vue.extend({
@@ -56,24 +103,46 @@ export default Vue.extend({
 	components: {
 		MkButton,
 		MkInput,
+		MkSelect,
 		MkPagination,
 	},
 
 	data() {
 		return {
+			target: '',
+			sort: '+createdAt',
+			state: 'all',
+			origin: 'local',
+			searchUsername: '',
+			searchHost: '',
 			pagination: {
 				endpoint: 'admin/show-users',
 				limit: 10,
 				params: () => ({
-					sort: '+createdAt'
+					sort: this.sort,
+					state: this.state,
+					origin: this.origin,
+					username: this.searchUsername,
+					hostname: this.searchHost,
 				}),
 				offsetMode: true
 			},
-			target: '',
-			faPlus, faUsers, faSearch
+			faPlus, faUsers, faSearch, faBookmark, farBookmark, faMicrophoneSlash, faSnowflake
 		}
 	},
 
+	watch: {
+		sort() {
+			this.$refs.users.reload();
+		},
+		state() {
+			this.$refs.users.reload();
+		},
+		origin() {
+			this.$refs.users.reload();
+		},
+	},
+
 	methods: {
 		/** テキストエリアのユーザーを解決する */
 		fetchUser() {
@@ -105,12 +174,16 @@ export default Vue.extend({
 		/** テキストエリアから処理対象ユーザーを設定する */
 		async showUser() {
 			const user = await this.fetchUser();
-			this.$root.api('admin/show-user', { userId: user.id }).then(info => {
-				this.show(user, info);
-			});
+			this.show(user);
 			this.target = '';
 		},
 
+		searchUser() {
+			this.$root.new(MkUserSelect, {}).$once('selected', user => {
+				this.show(user);
+			});
+		},
+
 		async addUser() {
 			const { canceled: canceled1, result: username } = await this.$root.dialog({
 				title: this.$t('username'),
@@ -148,19 +221,8 @@ export default Vue.extend({
 			});
 		},
 
-		async show(user, info) {
-			if (info == null) info = await this.$root.api('admin/show-user', { userId: user.id });
-			this.$root.new(MkUserModerateDialog, {
-				user: { ...user, ...info }
-			});
-		},
-
-		search() {
-			this.$root.new(MkUserSelect, {}).$once('selected', user => {
-				this.$root.api('admin/show-user', { userId: user.id }).then(info => {
-					this.show(user, info);
-				});
-			});
+		async show(user) {
+			this.$router.push('./users/' + user.id);
 		}
 	}
 });
@@ -182,20 +244,38 @@ export default Vue.extend({
 					align-items: center;
 
 					> .avatar {
-						width: 50px;
-						height: 50px;
+						width: 64px;
+						height: 64px;
 					}
 
 					> .body {
+						margin-left: 0.3em;
 						padding: 8px;
+						flex: 1;
 
-						> .name {
-							display: block;
-							font-weight: bold;
+						@media (max-width 500px) {
+							font-size: 14px;
 						}
 
-						> .acct {
-							opacity: 0.5;
+						> header {
+							> .name {
+								font-weight: bold;
+							}
+
+							> .acct {
+								margin-left: 8px;
+								opacity: 0.7;
+							}
+
+							> .staff {
+								margin-left: 0.5em;
+								color: var(--badge);
+							}
+
+							> .punished {
+								margin-left: 0.5em;
+								color: #4dabf7;
+							}
 						}
 					}
 				}
diff --git a/src/client/pages/user/index.photos.vue b/src/client/pages/user/index.photos.vue
index cd29254f48..3f8a24b64a 100644
--- a/src/client/pages/user/index.photos.vue
+++ b/src/client/pages/user/index.photos.vue
@@ -8,7 +8,7 @@
 			:href="image.note | notePage"
 		></a>
 	</div>
-	<p class="empty" v-if="!fetching && images.length == 0">{{ $t('no-photos') }}</p>
+	<p class="empty" v-if="!fetching && images.length == 0">{{ $t('nothing') }}</p>
 </div>
 </template>
 
diff --git a/src/client/pages/user/index.vue b/src/client/pages/user/index.vue
index 9f5f968901..75f61a0c0c 100644
--- a/src/client/pages/user/index.vue
+++ b/src/client/pages/user/index.vue
@@ -2,8 +2,10 @@
 <div class="mk-user-page" v-if="user">
 	<portal to="title" v-if="user"><mk-user-name :user="user" :nowrap="false" class="name"/></portal>
 	<portal to="avatar" v-if="user"><mk-avatar class="avatar" :user="user" :disable-preview="true"/></portal>
-	
+
 	<mk-remote-caution v-if="user.host != null" :href="user.url" style="margin-bottom: var(--margin)"/>
+	<div class="punished _panel" v-if="user.isSuspended"><fa :icon="faExclamationTriangle" style="margin-right: 8px;"/> {{ $t('userSuspended') }}</div>
+	<div class="punished _panel" v-if="user.isSilenced"><fa :icon="faExclamationTriangle" style="margin-right: 8px;"/> {{ $t('userSilenced') }}</div>
 	<div class="profile _panel" :key="user.id">
 		<div class="banner-container" :style="style">
 			<div class="banner" ref="banner" :style="style"></div>
@@ -105,7 +107,7 @@
 
 <script lang="ts">
 import Vue from 'vue';
-import { faEllipsisH, faRobot, faLock, faBookmark, faChartBar, faImage, faBirthdayCake, faMapMarker } from '@fortawesome/free-solid-svg-icons';
+import { faExclamationTriangle, faEllipsisH, faRobot, faLock, faBookmark, faChartBar, faImage, faBirthdayCake, faMapMarker } from '@fortawesome/free-solid-svg-icons';
 import { faCalendarAlt, faBookmark as farBookmark } from '@fortawesome/free-regular-svg-icons';
 import * as age from 's-age';
 import XUserTimeline from './index.timeline.vue';
@@ -139,7 +141,7 @@ export default Vue.extend({
 			user: null,
 			error: null,
 			parallaxAnimationId: null,
-			faEllipsisH, faRobot, faLock, faBookmark, farBookmark, faChartBar, faImage, faBirthdayCake, faMapMarker, faCalendarAlt
+			faExclamationTriangle, faEllipsisH, faRobot, faLock, faBookmark, farBookmark, faChartBar, faImage, faBirthdayCake, faMapMarker, faCalendarAlt
 		};
 	},
 
@@ -217,6 +219,12 @@ export default Vue.extend({
 
 <style lang="scss" scoped>
 .mk-user-page {
+
+	> .punished {
+		font-size: 0.8em;
+		padding: 16px;
+	}
+
 	> .profile {
 		position: relative;
 		margin-bottom: var(--margin);
diff --git a/src/client/router.ts b/src/client/router.ts
index 428be7ecca..d826d6b493 100644
--- a/src/client/router.ts
+++ b/src/client/router.ts
@@ -52,6 +52,7 @@ export const router = new VueRouter({
 		{ path: '/instance', component: page('instance/index') },
 		{ path: '/instance/emojis', component: page('instance/emojis') },
 		{ path: '/instance/users', component: page('instance/users') },
+		{ path: '/instance/users/:user', component: page('instance/users.user') },
 		{ path: '/instance/files', component: page('instance/files') },
 		{ path: '/instance/queue', component: page('instance/queue') },
 		{ path: '/instance/settings', component: page('instance/settings') },