From 246cead2b1e179a02d81793a5515688539c788cd Mon Sep 17 00:00:00 2001
From: syuilo <syuilotan@yahoo.co.jp>
Date: Fri, 23 Nov 2018 08:01:14 +0900
Subject: [PATCH] Improve user operations

Resolve #2197
Resolve #3367
---
 locales/ja-JP.yml                             |  23 ++-
 src/client/app/admin/views/announcements.vue  |   2 +-
 src/client/app/admin/views/emoji.vue          |   2 +-
 src/client/app/admin/views/users.vue          | 170 ++++++++++++++++--
 .../app/common/views/components/alert.vue     |   2 +-
 .../views/components/ui/horizon-group.vue     |  11 +-
 .../app/common/views/components/ui/input.vue  |  40 +++--
 .../app/common/views/components/ui/select.vue |  47 ++++-
 src/client/app/common/views/filters/index.ts  |   7 +
 src/client/app/common/views/filters/user.ts   |   5 +-
 src/misc/acct/parse.ts                        |   1 +
 src/models/user.ts                            |   4 +-
 src/remote/activitypub/models/note.ts         |   2 +-
 src/remote/activitypub/models/person.ts       |   4 +-
 .../api/endpoints/admin/reset-password.ts     |  57 ++++++
 src/server/api/endpoints/admin/show-user.ts   |  40 +++++
 src/server/api/endpoints/users.ts             |  45 ++++-
 src/server/api/endpoints/users/show.ts        |   2 +-
 src/services/note/create.ts                   |   3 +
 19 files changed, 404 insertions(+), 63 deletions(-)
 create mode 100644 src/server/api/endpoints/admin/reset-password.ts
 create mode 100644 src/server/api/endpoints/admin/show-user.ts

diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index 8a27a1ad3..a799c67c8 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -1151,16 +1151,35 @@ admin/views/charts.vue:
     network-usage: "通信量"
 
 admin/views/users.vue:
-  suspend-user: "ユーザーの凍結"
+  operation: "操作"
+  username-or-userid: "ユーザー名またはユーザーID"
+  user-not-found: "ユーザーが見つかりません"
+  lookup: "照会"
+  reset-password: "パスワードをリセット"
+  password-updated: "パスワードは現在「{password}」です"
   suspend: "凍結"
   suspended: "凍結しました"
   unsuspend: "凍結の解除"
   unsuspended: "凍結を解除しました"
-  verify-user: "ユーザーの公式アカウント設定"
   verify: "公式アカウントにする"
   verified: "公式アカウントにしました"
   unverify: "公式アカウントを解除する"
   unverified: "公式アカウントを解除しました"
+  users:
+    title: "ユーザー"
+    sort:
+      title: "ソート"
+      createdAtAsc: "登録日時が古い順"
+      createdAtDesc: "登録日時が新しい順"
+      updatedAtAsc: "最終更新日時が古い順"
+      updatedAtDesc: "最終更新日時が新しい順"
+    origin:
+      title: "オリジン"
+      combined: "ローカル+リモート"
+      local: "ローカル"
+      remote: "リモート"
+    createdAt: "登録日時"
+    updatedAt: "更新日時"
 
 admin/views/moderators.vue:
   add-moderator:
diff --git a/src/client/app/admin/views/announcements.vue b/src/client/app/admin/views/announcements.vue
index 31a2ab50b..42e926af4 100644
--- a/src/client/app/admin/views/announcements.vue
+++ b/src/client/app/admin/views/announcements.vue
@@ -9,7 +9,7 @@
 			<ui-textarea v-model="announcement.text">
 				<span>{{ $t('text') }}</span>
 			</ui-textarea>
-			<ui-horizon-group>
+			<ui-horizon-group class="fit-bottom">
 				<ui-button @click="save()"><fa :icon="['far', 'save']"/> {{ $t('save') }}</ui-button>
 				<ui-button @click="remove(i)"><fa :icon="['far', 'trash-alt']"/> {{ $t('remove') }}</ui-button>
 			</ui-horizon-group>
diff --git a/src/client/app/admin/views/emoji.vue b/src/client/app/admin/views/emoji.vue
index 6810340a3..31c6b0ebf 100644
--- a/src/client/app/admin/views/emoji.vue
+++ b/src/client/app/admin/views/emoji.vue
@@ -38,7 +38,7 @@
 				<i slot="icon"><fa icon="link"/></i>
 				<span>{{ $t('add-emoji.url') }}</span>
 			</ui-input>
-			<ui-horizon-group>
+			<ui-horizon-group class="fit-bottom">
 				<ui-button @click="updateEmoji(emoji)"><fa :icon="['far', 'save']"/> {{ $t('emojis.update') }}</ui-button>
 				<ui-button @click="removeEmoji(emoji)"><fa :icon="['far', 'trash-alt']"/> {{ $t('emojis.remove') }}</ui-button>
 			</ui-horizon-group>
diff --git a/src/client/app/admin/views/users.vue b/src/client/app/admin/views/users.vue
index 77ccf48e6..b71ae38c2 100644
--- a/src/client/app/admin/views/users.vue
+++ b/src/client/app/admin/views/users.vue
@@ -1,28 +1,63 @@
 <template>
 <div class="ucnffhbtogqgscfmqcymwmmupoknpfsw">
 	<ui-card>
-		<div slot="title"><fa :icon="faCertificate"/> {{ $t('verify-user') }}</div>
+		<div slot="title"><fa :icon="faTerminal"/> {{ $t('operation') }}</div>
 		<section class="fit-top">
-			<ui-input v-model="verifyUsername" type="text">
-				<span slot="prefix">@</span>
+			<ui-input v-model="target" type="text">
+				<span>{{ $t('username-or-userid') }}</span>
 			</ui-input>
+			<ui-button @click="resetPassword"><fa :icon="faKey"/> {{ $t('reset-password') }}</ui-button>
 			<ui-horizon-group>
-				<ui-button @click="verifyUser" :disabled="verifying">{{ $t('verify') }}</ui-button>
+				<ui-button @click="verifyUser" :disabled="verifying"><fa :icon="faCertificate"/> {{ $t('verify') }}</ui-button>
 				<ui-button @click="unverifyUser" :disabled="unverifying">{{ $t('unverify') }}</ui-button>
 			</ui-horizon-group>
+			<ui-horizon-group>
+				<ui-button @click="suspendUser" :disabled="suspending"><fa :icon="faSnowflake"/> {{ $t('suspend') }}</ui-button>
+				<ui-button @click="unsuspendUser" :disabled="unsuspending">{{ $t('unsuspend') }}</ui-button>
+			</ui-horizon-group>
+			<ui-button @click="showUser"><fa :icon="faSearch"/> {{ $t('lookup') }}</ui-button>
+			<ui-textarea v-if="user" :value="user | json5" readonly tall style="margin-top:16px;"></ui-textarea>
 		</section>
 	</ui-card>
 
 	<ui-card>
-		<div slot="title"><fa :icon="faSnowflake"/> {{ $t('suspend-user') }}</div>
+		<div slot="title"><fa :icon="faUsers"/> {{ $t('users.title') }}</div>
 		<section class="fit-top">
-			<ui-input v-model="suspendUsername" type="text">
-				<span slot="prefix">@</span>
-			</ui-input>
-			<ui-horizon-group>
-				<ui-button @click="suspendUser" :disabled="suspending">{{ $t('suspend') }}</ui-button>
-				<ui-button @click="unsuspendUser" :disabled="unsuspending">{{ $t('unsuspend') }}</ui-button>
+			<ui-horizon-group inputs>
+				<ui-select v-model="sort">
+					<span slot="label">{{ $t('users.sort.title') }}</span>
+					<option value="-createdAt">{{ $t('users.sort.createdAtAsc') }}</option>
+					<option value="+createdAt">{{ $t('users.sort.createdAtDesc') }}</option>
+					<option value="-updatedAt">{{ $t('users.sort.updatedAtAsc') }}</option>
+					<option value="+updatedAt">{{ $t('users.sort.updatedAtDesc') }}</option>
+				</ui-select>
+				<ui-select v-model="origin">
+					<span slot="label">{{ $t('users.origin.title') }}</span>
+					<option value="combined">{{ $t('users.origin.combined') }}</option>
+					<option value="local">{{ $t('users.origin.local') }}</option>
+					<option value="remote">{{ $t('users.origin.remote') }}</option>
+				</ui-select>
 			</ui-horizon-group>
+			<div class="kofvwchc" v-for="user in users">
+				<div>
+					<a :href="user | userPage(null, true)">
+						<mk-avatar class="avatar" :user="user" :disable-link="true"/>
+					</a>
+				</div>
+				<div>
+					<header>
+						<b>{{ user | userName }}</b>
+						<span class="username">@{{ user | acct }}</span>
+					</header>
+					<div>
+						<span>{{ $t('users.createdAt') }}: <mk-time :time="user.createdAt" mode="detail"/></span>
+					</div>
+					<div>
+						<span>{{ $t('users.updatedAt') }}: <mk-time :time="user.updatedAt" mode="detail"/></span>
+					</div>
+				</div>
+			</div>
+			<ui-button v-if="existMore" @click="fetchUsers">{{ $t('@.load-more') }}</ui-button>
 		</section>
 	</ui-card>
 </div>
@@ -32,7 +67,7 @@
 import Vue from 'vue';
 import i18n from '../../i18n';
 import parseAcct from "../../../../misc/acct/parse";
-import { faCertificate } from '@fortawesome/free-solid-svg-icons';
+import { faCertificate, faUsers, faTerminal, faSearch, faKey } from '@fortawesome/free-solid-svg-icons';
 import { faSnowflake } from '@fortawesome/free-regular-svg-icons';
 
 export default Vue.extend({
@@ -40,22 +75,81 @@ export default Vue.extend({
 
 	data() {
 		return {
-			verifyUsername: null,
+			user: null,
+			target: null,
 			verifying: false,
 			unverifying: false,
-			suspendUsername: null,
 			suspending: false,
 			unsuspending: false,
-			faCertificate, faSnowflake
+			sort: '+createdAt',
+			origin: 'combined',
+			limit: 10,
+			offset: 0,
+			users: [],
+			existMore: false,
+			faTerminal, faCertificate, faUsers, faSnowflake, faSearch, faKey
 		};
 	},
 
+	watch: {
+		sort() {
+			this.users = [];
+			this.offset = 0;
+			this.fetchUsers();
+		},
+
+		origin() {
+			this.users = [];
+			this.offset = 0;
+			this.fetchUsers();
+		}
+	},
+
+	mounted() {
+		this.fetchUsers();
+	},
+
 	methods: {
+		async fetchUser() {
+			try {
+				return await this.$root.api('users/show', this.target.startsWith('@') ? parseAcct(this.target) : { userId: this.target });
+			} catch (e) {
+				if (e == 'user not found') {
+					this.$root.alert({
+						type: 'error',
+						text: this.$t('user-not-found')
+					});
+				} else {
+					this.$root.alert({
+						type: 'error',
+						text: e.toString()
+					});
+				}
+			}
+		},
+
+		async showUser() {
+			const user = await this.fetchUser();
+			this.$root.api('admin/show-user', { userId: user.id }).then(info => {
+				this.user = info;
+			});
+		},
+
+		async resetPassword() {
+			const user = await this.fetchUser();
+			this.$root.api('admin/reset-password', { userId: user.id }).then(res => {
+				this.$root.alert({
+					type: 'success',
+					text: this.$t('password-updated', { password: res.password })
+				});
+			});
+		},
+
 		async verifyUser() {
 			this.verifying = true;
 
 			const process = async () => {
-				const user = await this.$root.api('users/show', parseAcct(this.verifyUsername));
+				const user = await this.fetchUser();
 				await this.$root.api('admin/verify-user', { userId: user.id });
 				this.$root.alert({
 					type: 'success',
@@ -77,7 +171,7 @@ export default Vue.extend({
 			this.unverifying = true;
 
 			const process = async () => {
-				const user = await this.$root.api('users/show', parseAcct(this.verifyUsername));
+				const user = await this.fetchUser();
 				await this.$root.api('admin/unverify-user', { userId: user.id });
 				this.$root.alert({
 					type: 'success',
@@ -99,7 +193,7 @@ export default Vue.extend({
 			this.suspending = true;
 
 			const process = async () => {
-				const user = await this.$root.api('users/show', parseAcct(this.suspendUsername));
+				const user = await this.fetchUser();
 				await this.$root.api('admin/suspend-user', { userId: user.id });
 				this.$root.alert({
 					type: 'success',
@@ -121,7 +215,7 @@ export default Vue.extend({
 			this.unsuspending = true;
 
 			const process = async () => {
-				const user = await this.$root.api('users/show', parseAcct(this.suspendUsername));
+				const user = await this.fetchUser();
 				await this.$root.api('admin/unsuspend-user', { userId: user.id });
 				this.$root.alert({
 					type: 'success',
@@ -137,6 +231,24 @@ export default Vue.extend({
 			});
 
 			this.unsuspending = false;
+		},
+
+		fetchUsers() {
+			this.$root.api('users', {
+				origin: this.origin,
+				sort: this.sort,
+				offset: this.offset,
+				limit: this.limit + 1
+			}).then(users => {
+				if (users.length == this.limit + 1) {
+					users.pop();
+					this.existMore = true;
+				} else {
+					this.existMore = false;
+				}
+				this.users = this.users.concat(users);
+				this.offset += this.limit;
+			});
 		}
 	}
 });
@@ -147,4 +259,24 @@ export default Vue.extend({
 	@media (min-width 500px)
 		padding 16px
 
+	.kofvwchc
+		display flex
+		padding 16px 0
+		border-top solid 1px var(--faceDivider)
+
+		> div:first-child
+			> a
+				> .avatar
+					width 64px
+					height 64px
+
+		> div:last-child
+			flex 1
+			padding-left 16px
+
+			> header
+				> .username
+					margin-left 8px
+					opacity 0.7
+
 </style>
diff --git a/src/client/app/common/views/components/alert.vue b/src/client/app/common/views/components/alert.vue
index 27d876c87..d48defe8a 100644
--- a/src/client/app/common/views/components/alert.vue
+++ b/src/client/app/common/views/components/alert.vue
@@ -5,7 +5,7 @@
 		<div class="icon" :class="type"><fa :icon="icon"/></div>
 		<header v-if="title" v-html="title"></header>
 		<div class="body" v-if="text" v-html="text"></div>
-		<ui-horizon-group no-grow class="buttons" v-if="!splash">
+		<ui-horizon-group no-grow class="buttons fit-bottom" v-if="!splash">
 			<ui-button @click="ok" primary autofocus>OK</ui-button>
 			<ui-button @click="cancel" v-if="showCancelButton">Cancel</ui-button>
 		</ui-horizon-group>
diff --git a/src/client/app/common/views/components/ui/horizon-group.vue b/src/client/app/common/views/components/ui/horizon-group.vue
index 0d4eafae5..cd3d32beb 100644
--- a/src/client/app/common/views/components/ui/horizon-group.vue
+++ b/src/client/app/common/views/components/ui/horizon-group.vue
@@ -27,9 +27,17 @@ export default Vue.extend({
 
 <style lang="stylus" scoped>
 .vnxwkwuf
+	margin 16px 0
+
 	&.inputs
 		margin 32px 0
 
+	&.fit-top
+		margin-top 0
+
+	&.fit-bottom
+		margin-bottom 0
+
 	&:not(.noGrow)
 		display flex
 
@@ -37,5 +45,6 @@ export default Vue.extend({
 			flex 1
 
 	> *:not(:last-child)
-		margin-right 16px
+		margin-right 16px !important
+
 </style>
diff --git a/src/client/app/common/views/components/ui/input.vue b/src/client/app/common/views/components/ui/input.vue
index 76bb34da6..4d77810b4 100644
--- a/src/client/app/common/views/components/ui/input.vue
+++ b/src/client/app/common/views/components/ui/input.vue
@@ -9,27 +9,30 @@
 		<div class="prefix" ref="prefix"><slot name="prefix"></slot></div>
 		<template v-if="type != 'file'">
 			<input ref="input"
-					:type="type"
-					v-model="v"
-					:disabled="disabled"
-					:required="required"
-					:readonly="readonly"
-					:pattern="pattern"
-					:autocomplete="autocomplete"
-					:spellcheck="spellcheck"
-					@focus="focused = true"
-					@blur="focused = false">
+				:type="type"
+				v-model="v"
+				:disabled="disabled"
+				:required="required"
+				:readonly="readonly"
+				:pattern="pattern"
+				:autocomplete="autocomplete"
+				:spellcheck="spellcheck"
+				@focus="focused = true"
+				@blur="focused = false"
+			>
 		</template>
 		<template v-else>
 			<input ref="input"
-					type="text"
-					:value="placeholder"
-					readonly
-					@click="chooseFile">
+				type="text"
+				:value="placeholder"
+				readonly
+				@click="chooseFile"
+			>
 			<input ref="file"
-					type="file"
-					:value="value"
-					@change="onChangeFile">
+				type="file"
+				:value="value"
+				@change="onChangeFile"
+			>
 		</template>
 		<div class="suffix" ref="suffix"><slot name="suffix"></slot></div>
 	</div>
@@ -325,6 +328,9 @@ root(fill)
 		margin 6px 0
 		font-size 13px
 
+		&:empty
+			display none
+
 		*
 			margin 0
 
diff --git a/src/client/app/common/views/components/ui/select.vue b/src/client/app/common/views/components/ui/select.vue
index da6f9696b..a2cb600bc 100644
--- a/src/client/app/common/views/components/ui/select.vue
+++ b/src/client/app/common/views/components/ui/select.vue
@@ -1,15 +1,17 @@
 <template>
-<div class="ui-select" :class="[{ focused, filled }, styl]">
+<div class="ui-select" :class="[{ focused, disabled, filled, inline }, styl]">
 	<div class="icon" ref="icon"><slot name="icon"></slot></div>
 	<div class="input" @click="focus">
 		<span class="label" ref="label"><slot name="label"></slot></span>
 		<div class="prefix" ref="prefix"><slot name="prefix"></slot></div>
 		<select ref="input"
-				:value="v"
-				:required="required"
-				@input="$emit('input', $event.target.value)"
-				@focus="focused = true"
-				@blur="focused = false">
+			:value="v"
+			:required="required"
+			:disabled="disabled"
+			@input="$emit('input', $event.target.value)"
+			@focus="focused = true"
+			@blur="focused = false"
+		>
 			<slot></slot>
 		</select>
 		<div class="suffix"><slot name="suffix"></slot></div>
@@ -22,6 +24,11 @@
 import Vue from 'vue';
 
 export default Vue.extend({
+	inject: {
+		horizonGrouped: {
+			default: false
+		}
+	},
 	props: {
 		value: {
 			required: false
@@ -30,11 +37,22 @@ export default Vue.extend({
 			type: Boolean,
 			required: false
 		},
+		disabled: {
+			type: Boolean,
+			required: false
+		},
 		styl: {
 			type: String,
 			required: false,
 			default: 'line'
-		}
+		},
+		inline: {
+			type: Boolean,
+			required: false,
+			default(): boolean {
+				return this.horizonGrouped;
+			}
+		},
 	},
 	data() {
 		return {
@@ -122,7 +140,7 @@ root(fill)
 			transition-duration 0.3s
 			font-size 16px
 			line-height 32px
-			color rgba(#000, 0.54)
+			color var(--inputLabel)
 			pointer-events none
 			//will-change transform
 			transform-origin top left
@@ -171,6 +189,9 @@ root(fill)
 		margin 6px 0
 		font-size 13px
 
+		&:empty
+			display none
+
 		*
 			margin 0
 
@@ -200,4 +221,14 @@ root(fill)
 	&:not(.fill)
 		root(false)
 
+	&.inline
+		display inline-block
+		margin 0
+
+	&.disabled
+		opacity 0.7
+
+		&, *
+			cursor not-allowed !important
+
 </style>
diff --git a/src/client/app/common/views/filters/index.ts b/src/client/app/common/views/filters/index.ts
index 1759c19c2..3dccbfc92 100644
--- a/src/client/app/common/views/filters/index.ts
+++ b/src/client/app/common/views/filters/index.ts
@@ -1,3 +1,10 @@
+import Vue from 'vue';
+import * as JSON5 from 'json5';
+
+Vue.filter('json5', x => {
+	return JSON5.stringify(x, null, 2);
+});
+
 require('./bytes');
 require('./number');
 require('./user');
diff --git a/src/client/app/common/views/filters/user.ts b/src/client/app/common/views/filters/user.ts
index e5220229b..9d4ae5c58 100644
--- a/src/client/app/common/views/filters/user.ts
+++ b/src/client/app/common/views/filters/user.ts
@@ -1,6 +1,7 @@
 import Vue from 'vue';
 import getAcct from '../../../../../misc/acct/render';
 import getUserName from '../../../../../misc/get-user-name';
+import { url } from '../../../config';
 
 Vue.filter('acct', user => {
 	return getAcct(user);
@@ -10,6 +11,6 @@ Vue.filter('userName', user => {
 	return getUserName(user);
 });
 
-Vue.filter('userPage', (user, path?) => {
-	return `/@${Vue.filter('acct')(user)}${(path ? `/${path}` : '')}`;
+Vue.filter('userPage', (user, path?, absolute = false) => {
+	return `${absolute ? url : ''}/@${Vue.filter('acct')(user)}${(path ? `/${path}` : '')}`;
 });
diff --git a/src/misc/acct/parse.ts b/src/misc/acct/parse.ts
index 0c00fccef..164bd7bcd 100644
--- a/src/misc/acct/parse.ts
+++ b/src/misc/acct/parse.ts
@@ -1,4 +1,5 @@
 export default (acct: string) => {
+	if (acct.startsWith('@')) acct = acct.substr(1);
 	const splitted = acct.split('@', 2);
 	return { username: splitted[0], host: splitted[1] || null };
 };
diff --git a/src/models/user.ts b/src/models/user.ts
index 22eecb571..bea0261dc 100644
--- a/src/models/user.ts
+++ b/src/models/user.ts
@@ -26,6 +26,7 @@ export default User;
 type IUserBase = {
 	_id: mongo.ObjectID;
 	createdAt: Date;
+	updatedAt?: Date;
 	deletedAt?: Date;
 	followersCount: number;
 	followingCount: number;
@@ -104,7 +105,6 @@ export interface ILocalUser extends IUserBase {
 		birthday: string; // 'YYYY-MM-DD'
 		tags: string[];
 	};
-	lastUsedAt: Date;
 	isCat: boolean;
 	isAdmin?: boolean;
 	isModerator?: boolean;
@@ -132,7 +132,7 @@ export interface IRemoteUser extends IUserBase {
 		id: string;
 		publicKeyPem: string;
 	};
-	updatedAt: Date;
+	lastFetchedAt: Date;
 	isAdmin: false;
 	isModerator: false;
 }
diff --git a/src/remote/activitypub/models/note.ts b/src/remote/activitypub/models/note.ts
index 48a02e79b..82d6d267c 100644
--- a/src/remote/activitypub/models/note.ts
+++ b/src/remote/activitypub/models/note.ts
@@ -104,7 +104,7 @@ export async function createNote(value: any, resolver?: Resolver, silent = false
 	});
 
 	// ユーザーの情報が古かったらついでに更新しておく
-	if (actor.updatedAt == null || Date.now() - actor.updatedAt.getTime() > 1000 * 60 * 60 * 24) {
+	if (actor.lastFetchedAt == null || Date.now() - actor.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
 		updatePerson(note.attributedTo);
 	}
 
diff --git a/src/remote/activitypub/models/person.ts b/src/remote/activitypub/models/person.ts
index d78bc15c9..b2ca2ecca 100644
--- a/src/remote/activitypub/models/person.ts
+++ b/src/remote/activitypub/models/person.ts
@@ -143,7 +143,7 @@ export async function createPerson(uri: string, resolver?: Resolver): Promise<IU
 			avatarId: null,
 			bannerId: null,
 			createdAt: Date.parse(person.published) || null,
-			updatedAt: new Date(),
+			lastFetchedAt: new Date(),
 			description: htmlToMFM(person.summary),
 			followersCount,
 			followingCount,
@@ -298,7 +298,7 @@ export async function updatePerson(uri: string, resolver?: Resolver, hint?: obje
 	// Update user
 	await User.update({ _id: exist._id }, {
 		$set: {
-			updatedAt: new Date(),
+			lastFetchedAt: new Date(),
 			inbox: person.inbox,
 			sharedInbox: person.sharedInbox,
 			featured: person.featured,
diff --git a/src/server/api/endpoints/admin/reset-password.ts b/src/server/api/endpoints/admin/reset-password.ts
new file mode 100644
index 000000000..c072c12e0
--- /dev/null
+++ b/src/server/api/endpoints/admin/reset-password.ts
@@ -0,0 +1,57 @@
+import $ from 'cafy';
+import ID, { transform } from '../../../../misc/cafy-id';
+import define from '../../define';
+import User from '../../../../models/user';
+import * as bcrypt from 'bcryptjs';
+import rndstr from 'rndstr';
+
+export const meta = {
+	desc: {
+		'ja-JP': '指定したユーザーのパスワードをリセットします。',
+	},
+
+	requireCredential: true,
+	requireModerator: true,
+
+	params: {
+		userId: {
+			validator: $.type(ID),
+			transform: transform,
+			desc: {
+				'ja-JP': '対象のユーザーID',
+				'en-US': 'The user ID which you want to suspend'
+			}
+		},
+	}
+};
+
+export default define(meta, (ps) => new Promise(async (res, rej) => {
+	const user = await User.findOne({
+		_id: ps.userId
+	});
+
+	if (user == null) {
+		return rej('user not found');
+	}
+
+	if (user.isAdmin) {
+		return rej('cannot reset password of admin');
+	}
+
+	const passwd = rndstr('a-zA-Z0-9', 8);
+
+	// Generate hash of password
+	const hash = bcrypt.hashSync(passwd);
+
+	await User.findOneAndUpdate({
+		_id: user._id
+	}, {
+			$set: {
+				password: hash
+			}
+		});
+
+	res({
+		password: passwd
+	});
+}));
diff --git a/src/server/api/endpoints/admin/show-user.ts b/src/server/api/endpoints/admin/show-user.ts
new file mode 100644
index 000000000..490b68535
--- /dev/null
+++ b/src/server/api/endpoints/admin/show-user.ts
@@ -0,0 +1,40 @@
+import $ from 'cafy';
+import ID, { transform } from '../../../../misc/cafy-id';
+import define from '../../define';
+import User from '../../../../models/user';
+
+export const meta = {
+	desc: {
+		'ja-JP': '指定したユーザーの情報を取得します。',
+	},
+
+	requireCredential: true,
+	requireModerator: true,
+
+	params: {
+		userId: {
+			validator: $.type(ID),
+			transform: transform,
+			desc: {
+				'ja-JP': '対象のユーザーID',
+				'en-US': 'The user ID which you want to suspend'
+			}
+		},
+	}
+};
+
+export default define(meta, (ps, me) => new Promise(async (res, rej) => {
+	const user = await User.findOne({
+		_id: ps.userId
+	});
+
+	if (user == null) {
+		return rej('user not found');
+	}
+
+	if (me.isModerator && user.isAdmin) {
+		return rej('cannot show info of admin');
+	}
+
+	res(user);
+}));
diff --git a/src/server/api/endpoints/users.ts b/src/server/api/endpoints/users.ts
index 203b4a53c..aef5bd850 100644
--- a/src/server/api/endpoints/users.ts
+++ b/src/server/api/endpoints/users.ts
@@ -17,7 +17,23 @@ export const meta = {
 		},
 
 		sort: {
-			validator: $.str.optional.or('+follower|-follower'),
+			validator: $.str.optional.or([
+				'+follower',
+				'-follower',
+				'+createdAt',
+				'-createdAt',
+				'+updatedAt',
+				'-updatedAt',
+			]),
+		},
+
+		origin: {
+			validator: $.str.optional.or([
+				'combined',
+				'local',
+				'remote',
+			]),
+			default: 'local'
 		}
 	}
 };
@@ -33,6 +49,22 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => {
 			_sort = {
 				followersCount: 1
 			};
+		} else if (ps.sort == '+createdAt') {
+			_sort = {
+				createdAt: -1
+			};
+		} else if (ps.sort == '+updatedAt') {
+			_sort = {
+				updatedAt: -1
+			};
+		} else if (ps.sort == '-createdAt') {
+			_sort = {
+				createdAt: 1
+			};
+		} else if (ps.sort == '-updatedAt') {
+			_sort = {
+				updatedAt: 1
+			};
 		}
 	} else {
 		_sort = {
@@ -40,14 +72,17 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => {
 		};
 	}
 
+	const q =
+		ps.origin == 'local' ? { host: null } :
+		ps.origin == 'remote' ? { host: { $ne: null } } :
+		{};
+
 	const users = await User
-		.find({
-			host: null
-		}, {
+		.find(q, {
 			limit: ps.limit,
 			sort: _sort,
 			skip: ps.offset
 		});
 
-	res(await Promise.all(users.map(user => pack(user, me))));
+	res(await Promise.all(users.map(user => pack(user, me, { detail: true }))));
 }));
diff --git a/src/server/api/endpoints/users/show.ts b/src/server/api/endpoints/users/show.ts
index 6e4cf514d..fd2655470 100644
--- a/src/server/api/endpoints/users/show.ts
+++ b/src/server/api/endpoints/users/show.ts
@@ -80,7 +80,7 @@ export default define(meta, (ps, me) => new Promise(async (res, rej) => {
 		}));
 
 		if (isRemoteUser(user)) {
-			if (user.updatedAt == null || Date.now() - user.updatedAt.getTime() > 1000 * 60 * 60 * 24) {
+			if (user.lastFetchedAt == null || Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
 				resolveRemoteUser(ps.username, ps.host, { }, true);
 			}
 		}
diff --git a/src/services/note/create.ts b/src/services/note/create.ts
index b512fe2dd..eac0185e7 100644
--- a/src/services/note/create.ts
+++ b/src/services/note/create.ts
@@ -633,6 +633,9 @@ function saveReply(reply: INote, note: INote) {
 
 function incNotesCountOfUser(user: IUser) {
 	User.update({ _id: user._id }, {
+		$set: {
+			updatedAt: new Date()
+		},
 		$inc: {
 			notesCount: 1
 		}