Improve user operations

Resolve #2197
Resolve #3367
This commit is contained in:
syuilo 2018-11-23 08:01:14 +09:00
parent c575b91a59
commit 283f792c32
19 changed files with 404 additions and 63 deletions

View file

@ -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:

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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>

View file

@ -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');

View file

@ -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}` : '')}`;
});

View file

@ -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 };
};

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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,

View file

@ -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
});
}));

View file

@ -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);
}));

View file

@ -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 }))));
}));

View file

@ -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);
}
}

View file

@ -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
}