rewrite users management page with React

This commit is contained in:
Pig Fang 2020-05-13 18:12:01 +08:00
parent f795194dc3
commit 3e1a10a461
22 changed files with 2031 additions and 1580 deletions

View File

@ -7,7 +7,6 @@ use App\Models\Texture;
use App\Models\User;
use App\Services\OptionForm;
use App\Services\PluginManager;
use Auth;
use Blessing\Filter;
use Cache;
use Carbon\Carbon;
@ -416,120 +415,4 @@ class AdminController extends Controller
])
->with('plugins', $enabledPlugins);
}
public function getUserData(Request $request)
{
$isSingleUser = $request->has('uid');
if ($isSingleUser) {
$users = User::select(['uid', 'email', 'nickname', 'score', 'permission', 'register_at', 'verified'])
->where('uid', intval($request->input('uid')))
->get();
} else {
$search = $request->input('search', '');
$sortField = $request->input('sortField', 'uid');
$sortType = $request->input('sortType', 'asc');
$page = $request->input('page', 1);
$perPage = $request->input('perPage', 10);
$users = User::select(['uid', 'email', 'nickname', 'score', 'permission', 'register_at', 'verified'])
->where('uid', 'like', '%'.$search.'%')
->orWhere('email', 'like', '%'.$search.'%')
->orWhere('nickname', 'like', '%'.$search.'%')
->orWhere('score', 'like', '%'.$search.'%')
->orderBy($sortField, $sortType)
->offset(($page - 1) * $perPage)
->limit($perPage)
->get();
}
$users->transform(function ($user) {
$user->operations = auth()->user()->permission;
$user->players_count = $user->players->count();
return $user;
});
return [
'totalRecords' => $isSingleUser ? 1 : User::count(),
'data' => $users,
];
}
public function userAjaxHandler(Request $request)
{
$action = $request->input('action');
$user = User::find($request->uid);
$currentUser = Auth::user();
if (!$user) {
return json(trans('admin.users.operations.non-existent'), 1);
}
if ($user->uid !== $currentUser->uid && $user->permission >= $currentUser->permission) {
return json(trans('admin.users.operations.no-permission'), 1);
}
if ($action == 'email') {
$this->validate($request, [
'email' => 'required|email',
]);
if (User::where('email', $request->email)->count() != 0) {
return json(trans('admin.users.operations.email.existed', ['email' => $request->input('email')]), 1);
}
$user->email = $request->input('email');
$user->save();
return json(trans('admin.users.operations.email.success'), 0);
} elseif ($action == 'verification') {
$user->verified = !$user->verified;
$user->save();
return json(trans('admin.users.operations.verification.success'), 0);
} elseif ($action == 'nickname') {
$this->validate($request, ['nickname' => 'required']);
$user->nickname = $request->input('nickname');
$user->save();
return json(trans('admin.users.operations.nickname.success', [
'new' => $request->input('nickname'),
]), 0);
} elseif ($action == 'password') {
$this->validate($request, [
'password' => 'required|min:8|max:16',
]);
$user->changePassword($request->input('password'));
return json(trans('admin.users.operations.password.success'), 0);
} elseif ($action == 'score') {
$this->validate($request, [
'score' => 'required|integer',
]);
$user->score = $request->input('score');
$user->save();
return json(trans('admin.users.operations.score.success'), 0);
} elseif ($action == 'permission') {
$user->permission = $this->validate($request, [
'permission' => 'required|in:-1,0,1',
])['permission'];
$user->save();
return json([
'code' => 0,
'message' => trans('admin.users.operations.permission'),
]);
} elseif ($action == 'delete') {
$user->delete();
return json(trans('admin.users.operations.delete.success'), 0);
} else {
return json(trans('admin.users.operations.invalid'), 1);
}
}
}

View File

@ -0,0 +1,170 @@
<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Contracts\Events\Dispatcher;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class UsersManagementController extends Controller
{
public function __construct()
{
$this->middleware(function (Request $request, $next) {
/** @var User */
$targetUser = $request->route('user');
/** @var User */
$authUser = $request->user();
if (
$targetUser->isNot($authUser) &&
$targetUser->permission >= $authUser->permission
) {
return json(trans('admin.users.operations.no-permission'), 1)
->setStatusCode(403);
}
return $next($request);
})->except(['list']);
}
public function list(Request $request)
{
$q = $request->input('q');
return User::usingSearchString($q)->paginate(10);
}
public function email(User $user, Request $request, Dispatcher $dispatcher)
{
$data = $request->validate([
'email' => [
'required', 'email', Rule::unique('users')->ignore($user),
],
]);
$email = $data['email'];
$dispatcher->dispatch('user.email.updating', [$user, $email]);
$old = $user->replicate();
$user->email = $email;
$user->save();
$dispatcher->dispatch('user.email.updated', [$user, $old]);
return json(trans('admin.users.operations.email.success'), 0);
}
public function verification(User $user, Dispatcher $dispatcher)
{
$dispatcher->dispatch('user.verification.updating', [$user]);
$user->verified = !$user->verified;
$user->save();
$dispatcher->dispatch('user.verification.updated', [$user]);
return json(trans('admin.users.operations.verification.success'), 0);
}
public function nickname(User $user, Request $request, Dispatcher $dispatcher)
{
$data = $request->validate([
'nickname' => 'required|string',
]);
$nickname = $data['nickname'];
$dispatcher->dispatch('user.nickname.updating', [$user, $nickname]);
$old = $user->replicate();
$user->nickname = $nickname;
$user->save();
$dispatcher->dispatch('user.nickname.updated', [$user, $old]);
return json(trans('admin.users.operations.nickname.success', [
'new' => $request->input('nickname'),
]), 0);
}
public function password(User $user, Request $request, Dispatcher $dispatcher)
{
$data = $request->validate([
'password' => 'required|string|min:8|max:16',
]);
$password = $data['password'];
$dispatcher->dispatch('user.password.updating', [$user, $password]);
$user->changePassword($password);
$user->save();
$dispatcher->dispatch('user.password.updated', [$user]);
return json(trans('admin.users.operations.password.success'), 0);
}
public function score(User $user, Request $request, Dispatcher $dispatcher)
{
$data = $request->validate([
'score' => 'required|integer',
]);
$score = (int) $data['score'];
$dispatcher->dispatch('user.score.updating', [$user, $score]);
$old = $user->replicate();
$user->score = $score;
$user->save();
$dispatcher->dispatch('user.score.updated', [$user, $old]);
return json(trans('admin.users.operations.score.success'), 0);
}
public function permission(User $user, Request $request, Dispatcher $dispatcher)
{
$data = $request->validate([
'permission' => [
'required',
Rule::in([User::BANNED, User::NORMAL, User::ADMIN]),
],
]);
$permission = (int) $data['permission'];
if (
$permission === User::ADMIN &&
$request->user()->permission < User::SUPER_ADMIN
) {
return json(trans('admin.users.operations.no-permission'), 1)
->setStatusCode(403);
}
if ($user->is($request->user())) {
return json(trans('admin.users.operations.no-permission'), 1)
->setStatusCode(403);
}
$dispatcher->dispatch('user.permission.updating', [$user, $permission]);
$old = $user->replicate();
$user->permission = $permission;
$user->save();
$dispatcher->dispatch('user.permission.updated', [$user, $old]);
return json(trans('admin.users.operations.permission'), 0);
}
public function delete(User $user, Dispatcher $dispatcher)
{
$dispatcher->dispatch('user.deleting', [$user]);
$user->delete();
$dispatcher->dispatch('user.deleted', [$user]);
return json(trans('admin.users.operations.delete.success'), 0);
}
}

View File

@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Passport\HasApiTokens;
use Lorisleiva\LaravelSearchString\Concerns\SearchString;
use Tymon\JWTAuth\Contracts\JWTSubject;
/**
@ -31,6 +32,7 @@ class User extends Authenticatable implements JWTSubject
use Notifiable;
use HasPassword;
use HasApiTokens;
use SearchString;
const BANNED = -1;
const NORMAL = 0;
@ -51,6 +53,16 @@ class User extends Authenticatable implements JWTSubject
protected $hidden = ['password', 'remember_token'];
protected $searchStringColumns = [
'uid',
'email' => ['searchable' => true],
'nickname' => ['searchable' => true],
'avatar', 'score', 'permission', 'ip',
'last_sign_at' => ['date' => true],
'register_at' => ['date' => true],
'verified' => ['boolean' => true],
];
public function isAdmin(): bool
{
return $this->permission >= static::ADMIN;

View File

@ -1,198 +0,0 @@
<template>
<div
:id="id"
class="modal fade"
tabindex="-1"
role="dialog"
aria-hidden="true"
>
<div class="modal-dialog" :class="{ 'modal-dialog-centered': center }" role="document">
<div class="modal-content" :class="[`bg-${type}`]">
<div v-if="showHeader" class="modal-header">
<h5 class="modal-title">{{ title }}</h5>
<button
type="button"
class="close"
data-dismiss="modal"
aria-label="Close"
@click="dismiss"
>
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<slot>
<template v-if="text">
<p v-for="(line, i) in lines" :key="i">{{ line }}</p>
</template>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-else-if="dangerousHTML" v-html="dangerousHTML" />
<template v-if="mode === 'prompt'">
<div class="form-group">
<input
v-model="value"
:type="inputType"
class="form-control"
:placeholder="placeholder"
@input="valid = true"
>
</div>
<div v-if="!valid" class="alert alert-danger">
<i class="icon far fa-times-circle" />
{{ validatorMessage }}
</div>
</template>
</slot>
</div>
<div class="modal-footer" :class="footerClasses">
<slot name="footer">
<button
v-if="mode !== 'alert'"
type="button"
class="btn"
:class="[`btn-${cancelButtonType}`]"
data-dismiss="modal"
@click="dismiss"
>
{{ cancelButtonText }}
</button>
<button
type="button"
class="btn"
:class="[`btn-${okButtonType}`]"
@click="confirm"
>
{{ okButtonText }}
</button>
</slot>
</div>
</div>
</div>
</div>
</template>
<script>
import $ from 'jquery'
import { trans } from '../scripts/i18n'
export default {
name: 'Modal',
props: {
mode: {
type: String,
default: 'confirm',
validator: value => ['alert', 'confirm', 'prompt'].includes(value),
},
id: {
type: String,
},
title: {
type: String,
default: trans('general.tip'),
},
text: {
type: String,
default: '',
},
dangerousHTML: {
type: String,
},
input: {
type: String,
default: '',
},
placeholder: {
type: String,
},
inputType: {
type: String,
default: 'text',
},
validator: {
type: Function,
},
type: {
type: String,
default: 'default',
},
showHeader: {
type: Boolean,
default: true,
},
center: {
type: Boolean,
default: false,
},
okButtonText: {
type: String,
default: trans('general.confirm'),
},
okButtonType: {
type: String,
default: 'primary',
},
cancelButtonText: {
type: String,
default: trans('general.cancel'),
},
cancelButtonType: {
type: String,
default: 'secondary',
},
flexFooter: {
type: Boolean,
default: false,
},
},
data() {
return {
hidden: false,
value: this.input,
valid: true,
validatorMessage: '',
}
},
computed: {
lines() {
return this.text.split(/\r?\n/)
},
footerClasses() {
return {
'd-flex': this.flexFooter,
'justify-content-between': this.flexFooter,
}
},
},
mounted() {
$(this.$el)
.on('hide.bs.modal', () => {
if (!this.hidden) {
this.dismiss()
}
})
.on('hidden.bs.modal', () => {
this.hidden = false
})
},
methods: {
confirm() {
if (typeof this.validator === 'function') {
const result = this.validator(this.value)
if (typeof result === 'string') {
this.validatorMessage = result
this.valid = false
return
}
}
this.hidden = true
this.$emit('confirm', { value: this.value })
$(this.$el).modal('hide')
},
dismiss() {
this.hidden = true
this.$emit('dismiss')
},
},
}
</script>

View File

@ -53,7 +53,7 @@ export default [
},
{
path: 'admin/users',
component: () => import('../views/admin/Users.vue'),
react: () => import('../views/admin/UsersManagement'),
el: '.content > .container-fluid',
},
{

View File

@ -4,13 +4,20 @@ export type User = {
nickname: string
score: number
avatar: number
permission: number
permission: UserPermission
ip: string
last_sign_at: string
register_at: string
verified: boolean
}
export const enum UserPermission {
Banned = -1,
Normal = 0,
Admin = 1,
SuperAdmin = 2,
}
export type Player = {
pid: number
name: string

View File

@ -1,362 +0,0 @@
<template>
<div class="container-fluid">
<vue-good-table
mode="remote"
:rows="users"
:total-rows="totalRecords || users.length"
:columns="columns"
:search-options="tableOptions.search"
:pagination-options="tableOptions.pagination"
style-class="vgt-table striped"
@on-page-change="onPageChange"
@on-sort-change="onSortChange"
@on-search="onSearch"
@on-per-page-change="onPerPageChange"
>
<template #table-row="props">
<span v-if="props.column.field === 'email'">
{{ props.formattedRow[props.column.field] }}
<a :title="$t('admin.changeEmail')" data-test="email" @click="changeEmail(props.row)">
<i class="fas fa-edit btn-edit" />
</a>
</span>
<span v-else-if="props.column.field === 'nickname'">
{{ props.formattedRow[props.column.field] }}
<a :title="$t('admin.changeNickName')" data-test="nickname" @click="changeNickName(props.row)">
<i class="fas fa-edit btn-edit" />
</a>
</span>
<span v-else-if="props.column.field === 'score'">
{{ props.formattedRow[props.column.field] }}
<a :title="$t('admin.changeScore')" data-test="score" @click="changeScore(props.row)">
<i class="fas fa-edit btn-edit" />
</a>
</span>
<span v-else-if="props.column.field === 'players_count'">
<a
:href="props.row | playersLink"
:title="$t('admin.inspectHisPlayers')"
data-toggle="tooltip"
data-placement="right"
>{{ props.formattedRow[props.column.field] }}</a>
</span>
<span v-else-if="props.column.field === 'permission'">
<span>{{ props.row | humanizePermission }}</span>
<a
v-if="props.row.permission < 1 || (props.row.operations === 2 && props.row.permission < 2)"
:title="$t('admin.changePermission')"
data-toggle="modal"
data-target="#modal-permission"
data-test="permission"
@click="editingUser = props.row, permission = props.row.permission"
>
<i class="fas fa-edit btn-edit" />
</a>
</span>
<span v-else-if="props.column.field === 'verified'">
<span v-if="props.row.verified" v-t="'admin.verified'" />
<span v-else v-t="'admin.unverified'" />
<a
:title="$t('admin.toggleVerification')"
data-test="verification"
@click="toggleVerification(props.row)"
>
<i
class="fas btn-edit"
:class="{ 'fa-toggle-on': props.row.verified, 'fa-toggle-off': !props.row.verified }"
/>
</a>
</span>
<div v-else-if="props.column.field === 'operations'">
<button class="btn btn-default" @click="changePassword(props.row)">
{{ $t('admin.changePassword') }}
</button>
<button
:disabled="props.row.permission >= 2 || (props.row.operations === 1 && props.row.permission >= 1)"
class="btn btn-danger"
data-test="deleteUser"
@click="deleteUser(props.row)"
>
{{ $t('admin.deleteUser') }}
</button>
</div>
<span v-else v-text="props.formattedRow[props.column.field]" />
</template>
</vue-good-table>
<modal
id="modal-permission"
:title="$t(this.$t('admin.newPermission'))"
center
@confirm="changePermission"
>
<label class="mr-3">
<input
v-model="permission"
type="radio"
name="permission"
:value="-1"
>
{{ $t('admin.banned') }}
</label>
<label class="mr-3">
<input
v-model="permission"
type="radio"
name="permission"
:value="0"
>
{{ $t('admin.normal') }}
</label>
<label v-if="editingUser.operations === 2">
<input
v-model="permission"
type="radio"
name="permission"
:value="1"
>
{{ $t('admin.admin') }}
</label>
</modal>
</div>
</template>
<script>
import { VueGoodTable } from 'vue-good-table'
import 'vue-good-table/dist/vue-good-table.min.css'
import { trans } from '../../scripts/i18n'
import Modal from '../../components/Modal.vue'
import tableOptions from '../../components/mixins/tableOptions'
import serverTable from '../../components/mixins/serverTable'
import emitMounted from '../../components/mixins/emitMounted'
import { showModal, toast } from '../../scripts/notify'
import { truthy } from '../../scripts/validators'
export default {
name: 'UsersManagement',
components: {
Modal,
VueGoodTable,
},
filters: {
humanizePermission(user) {
switch (user.permission) {
case -1:
return trans('admin.banned')
case 0:
return trans('admin.normal')
case 1:
return trans('admin.admin')
case 2:
return trans('admin.superAdmin')
default:
return ''
}
},
playersLink(user) {
return `${blessing.base_url}/admin/players?uid=${user.uid}`
},
},
mixins: [
emitMounted,
tableOptions,
serverTable,
],
data() {
return {
users: [],
columns: [
{
field: 'uid', label: 'UID', type: 'number',
},
{ field: 'email', label: this.$t('general.user.email') },
{
field: 'nickname', label: this.$t('general.user.nickname'), width: '150px',
},
{
field: 'score', label: this.$t('general.user.score'), type: 'number', width: '102px',
},
{
field: 'players_count', label: this.$t('admin.playersCount'), type: 'number', sortable: false,
},
{
field: 'permission', label: this.$t('admin.permission'), globalSearchDisabled: true,
},
{
field: 'verified', label: this.$t('admin.verification'), type: 'boolean', globalSearchDisabled: true,
},
{ field: 'register_at', label: this.$t('general.user.register-at') },
{
field: 'operations', label: this.$t('admin.operationsTitle'), sortable: false, globalSearchDisabled: true,
},
],
editingUser: {},
permission: 0,
}
},
beforeMount() {
this.fetchData()
},
methods: {
async fetchData() {
const { data, totalRecords } = await this.$http.get(
`/admin/users/list${location.search}`,
!location.search && this.serverParams,
)
this.totalRecords = totalRecords
this.users = data
},
async changeEmail(user) {
let value
try {
({ value } = await showModal({
mode: 'prompt',
text: this.$t('admin.newUserEmail'),
input: user.email,
validator: truthy(this.$t('auth.emptyEmail')),
}))
} catch {
return
}
const { code, message } = await this.$http.post(
'/admin/users?action=email',
{ uid: user.uid, email: value },
)
if (code === 0) {
user.email = value
toast.success(message)
} else {
toast.error(message)
}
},
async toggleVerification(user) {
const { code, message } = await this.$http.post(
'/admin/users?action=verification',
{ uid: user.uid },
)
if (code === 0) {
user.verified = !user.verified
toast.success(message)
} else {
toast.error(message)
}
},
async changeNickName(user) {
let value
try {
({ value } = await showModal({
mode: 'prompt',
text: this.$t('admin.newUserNickname'),
input: user.nickname,
validator: truthy(this.$t('auth.emptyNickname')),
}))
} catch {
return
}
const { code, message } = await this.$http.post(
'/admin/users?action=nickname',
{ uid: user.uid, nickname: value },
)
if (code === 0) {
user.nickname = value
toast.success(message)
} else {
toast.error(message)
}
},
async changePassword(user) {
let value
try {
({ value } = await showModal({
mode: 'prompt',
text: this.$t('admin.newUserPassword'),
inputType: 'password',
}))
} catch {
return
}
const { code, message } = await this.$http.post(
'/admin/users?action=password',
{ uid: user.uid, password: value },
)
code === 0 ? toast.success(message) : toast.error(message)
},
async changeScore(user) {
let value
try {
({ value } = await showModal({
mode: 'prompt',
text: this.$t('admin.newScore'),
input: user.score,
inputType: 'number',
}))
} catch {
return
}
const score = Number.parseInt(value)
const { code, message } = await this.$http.post(
'/admin/users?action=score',
{ uid: user.uid, score },
)
if (code === 0) {
user.score = score
toast.success(message)
} else {
toast.error(message)
}
},
async changePermission() {
const permission = Number.parseInt(this.permission)
const { code, message } = await this.$http.post('/admin/users?action=permission', {
uid: this.editingUser.uid,
permission,
})
if (code === 0) {
this.editingUser.permission = permission
toast.success(message)
} else {
toast.error(message)
}
},
async deleteUser({ uid, originalIndex }) {
try {
await showModal({
text: this.$t('admin.deleteUserNotice'),
okButtonType: 'danger',
})
} catch {
return
}
const { code, message } = await this.$http.post(
'/admin/users?action=delete',
{ uid },
)
if (code === 0) {
this.$delete(this.users, originalIndex)
toast.success(message)
} else {
toast.error(message)
}
},
},
}
</script>
<style lang="stylus">
.operations-menu
margin-left -35px
.fa-edit
cursor pointer
.fa-toggle-on, .fa-toggle-off
font-size 18px
cursor pointer
.row-at-bottom
margin-top -100px
</style>

View File

@ -0,0 +1,27 @@
@use '../../../styles/breakpoints';
.box {
width: 48%;
margin: 7px;
@include breakpoints.less-than('lg') {
width: 98%;
}
}
.icon {
width: 70px;
display: flex;
justify-content: center;
padding-top: 22px;
}
$border: 1px solid rgba(0, 0, 0, 0.125);
.border {
@include breakpoints.less-than('sm') {
border-bottom: $border;
}
@include breakpoints.greater-than('sm') {
border-right: $border;
}
}

View File

@ -0,0 +1,157 @@
import React from 'react'
import { t } from '@/scripts/i18n'
import { User } from '@/scripts/types'
import {
humanizePermission,
verificationStatusText,
canModifyUser,
canModifyPermission,
} from './utils'
import styles from './Card.module.scss'
interface Props {
user: User
currentUser: User
onEmailChange(): void
onNicknameChange(): void
onScoreChange(): void
onPermissionChange(): void
onVerificationToggle(): void
onPasswordChange(): void
onDelete(): void
}
const Card: React.FC<Props> = (props) => {
const { user, currentUser } = props
const canModify = canModifyUser(user, currentUser)
return (
<div className={`info-box ${styles.box}`}>
<div className={styles.icon}>
<img
className="bs-avatar"
src={`${blessing.base_url}/avatar/user/${user.uid}`}
/>
</div>
<div className="info-box-content">
<div className="row">
<div className="col-10">
<b>{user.nickname}</b>
</div>
<div className="col-2">
{canModify && (
<div className="float-right dropdown">
<a
className="text-gray"
href="#"
data-toggle="dropdown"
aria-expanded="false"
>
<i className="fas fa-cog"></i>
</a>
<div className="dropdown-menu dropdown-menu-right">
<a
href="#"
className="dropdown-item"
onClick={props.onEmailChange}
>
<i className="fas fa-at mr-2"></i>
{t('admin.changeEmail')}
</a>
<a
href="#"
className="dropdown-item"
onClick={props.onNicknameChange}
>
<i className="fas fa-signature mr-2"></i>
{t('admin.changeNickName')}
</a>
<a
href="#"
className="dropdown-item"
onClick={props.onPasswordChange}
>
<i className="fas fa-asterisk mr-2"></i>
{t('admin.changePassword')}
</a>
<div className="dropdown-divider"></div>
<a
href="#"
className="dropdown-item"
onClick={props.onScoreChange}
>
<i className="fas fa-coins mr-2"></i>
{t('admin.changeScore')}
</a>
{canModifyPermission(user, currentUser) && (
<a
href="#"
className="dropdown-item"
onClick={props.onPermissionChange}
>
<i className="fas fa-user-secret mr-2"></i>
{t('admin.changePermission')}
</a>
)}
<a
href="#"
className="dropdown-item"
onClick={props.onVerificationToggle}
>
<i className="fas fa-user-check mr-2"></i>
{t('admin.toggleVerification')}
</a>
<div className="dropdown-divider"></div>
<a
href="#"
className="dropdown-item dropdown-item-danger"
onClick={props.onDelete}
>
<i className="fas fa-trash mr-2"></i>
{t('admin.deleteUser')}
</a>
</div>
</div>
)}
</div>
</div>
<div>
<div>UID: {user.uid}</div>
<div>
{t('general.user.email')}
{': '}
<span>{user.email}</span>
</div>
<div className="row m-2 border-top border-bottom">
<div className={`col-sm-4 py-1 text-center ${styles.border}`}>
<b className="d-block">{t('general.user.score')}</b>
<span className="d-block py-1">{user.score}</span>
</div>
<div className={`col-sm-4 py-1 text-center ${styles.border}`}>
<b className="d-block">{t('admin.permission')}</b>
<span className="d-block py-1">
{humanizePermission(user.permission)}
</span>
</div>
<div className="col-sm-4 py-1 text-center">
<b className="d-block">{t('admin.verification')}</b>
<span className="d-block py-1">
{verificationStatusText(user.verified)}
</span>
</div>
</div>
<div>
<small className="text-gray">
{t('general.user.register-at')}
{': '}
{user.register_at}
</small>
</div>
</div>
</div>
</div>
)
}
export default Card

View File

@ -0,0 +1,114 @@
import React from 'react'
import { t } from '@/scripts/i18n'
import { User } from '@/scripts/types'
import ButtonEdit from '@/components/ButtonEdit'
import {
humanizePermission,
verificationStatusText,
canModifyUser,
canModifyPermission,
} from './utils'
interface Props {
user: User
currentUser: User
onEmailChange(): void
onNicknameChange(): void
onScoreChange(): void
onPermissionChange(): void
onVerificationToggle(): void
onPasswordChange(): void
onDelete(): void
}
const Row: React.FC<Props> = (props) => {
const { user, currentUser } = props
const canModify = canModifyUser(user, currentUser)
return (
<tr>
<td>{user.uid}</td>
<td>
{user.email}
{canModify && (
<span className="ml-1">
<ButtonEdit
title={t('admin.changeEmail')}
onClick={props.onEmailChange}
/>
</span>
)}
</td>
<td>
{user.nickname}
{canModify && (
<span className="ml-1">
<ButtonEdit
title={t('admin.changeNickName')}
onClick={props.onNicknameChange}
/>
</span>
)}
</td>
<td>
{user.score}
{canModify && (
<span className="ml-1">
<ButtonEdit
title={t('admin.changeScore')}
onClick={props.onScoreChange}
/>
</span>
)}
</td>
<td>
{humanizePermission(user.permission)}
{canModifyPermission(user, currentUser) && (
<span className="ml-1">
<ButtonEdit
title={t('admin.changePermission')}
onClick={props.onPermissionChange}
/>
</span>
)}
</td>
<td>
{verificationStatusText(user.verified)}
{canModify && (
<a
className="ml-1"
href="#"
title={t('admin.toggleVerification')}
onClick={props.onVerificationToggle}
>
{user.verified ? (
<i className="fas fa-toggle-on"></i>
) : (
<i className="fas fa-toggle-off"></i>
)}
</a>
)}
</td>
<td>{user.register_at}</td>
<td>
<button
className="btn btn-default mr-2"
disabled={!canModify}
onClick={props.onPasswordChange}
>
{t('admin.changePassword')}
</button>
<button
className="btn btn-danger"
disabled={!canModify}
onClick={props.onDelete}
>
{t('admin.deleteUser')}
</button>
</td>
</tr>
)
}
export default Row

View File

@ -0,0 +1,364 @@
import React, { useState, useEffect } from 'react'
import { hot } from 'react-hot-loader/root'
import { useImmer } from 'use-immer'
import useBlessingExtra from '@/scripts/hooks/useBlessingExtra'
import useIsLargeScreen from '@/scripts/hooks/useIsLargeScreen'
import { t } from '@/scripts/i18n'
import * as fetch from '@/scripts/net'
import { User, UserPermission, Paginator } from '@/scripts/types'
import { toast, showModal } from '@/scripts/notify'
import modeSwitchStyles from '@/styles/table-mode-switch.module.scss'
import type { Props as ModalInputProps } from '@/components/ModalInput'
import Loading from '@/components/Loading'
import Pagination from '@/components/Pagination'
import Card from './Card'
import Row from './Row'
const UsersManagement: React.FC = () => {
const [users, setUsers] = useImmer<User[]>([])
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const isLargeScreen = useIsLargeScreen()
const [isTableMode, setIsTableMode] = useState(false)
const [query, setQuery] = useState('')
const currentUser = useBlessingExtra<User>('currentUser', {
uid: 0,
permission: UserPermission.Admin,
} as User)
useEffect(() => {
if (isLargeScreen) {
setIsTableMode(true)
}
}, [isLargeScreen])
const getUsers = async () => {
setIsLoading(true)
const { data, last_page }: Paginator<User> = await fetch.get(
'/admin/users/list',
{
q: query,
page,
},
)
setUsers(() => data)
setTotalPages(last_page)
setIsLoading(false)
}
useEffect(() => {
getUsers()
}, [page])
const handleModeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setIsTableMode(event.target.value === 'table')
}
const handleQueryChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setQuery(event.target.value)
}
const handleSubmitQuery = (event: React.FormEvent) => {
event.preventDefault()
getUsers()
}
const handleEmailChange = async (user: User, index: number) => {
let email: string
try {
const { value } = await showModal({
mode: 'prompt',
text: t('admin.newUserEmail'),
input: user.email,
validator: (value: string) => {
if (!value) {
return t('auth.emptyEmail')
}
},
})
email = value
} catch {
return
}
const { code, message } = await fetch.put<fetch.ResponseBody>(
`/admin/users/${user.uid}/email`,
{ email },
)
if (code === 0) {
toast.success(message)
setUsers((users) => {
users[index].email = email
})
} else {
toast.error(message)
}
}
const handleNicknameChange = async (user: User, index: number) => {
let nickname: string
try {
const { value } = await showModal({
mode: 'prompt',
text: t('admin.newUserNickname'),
input: user.nickname,
validator: (value: string) => {
if (!value) {
return t('auth.emptyNickname')
}
},
})
nickname = value
} catch {
return
}
const { code, message } = await fetch.put<fetch.ResponseBody>(
`/admin/users/${user.uid}/nickname`,
{ nickname },
)
if (code === 0) {
toast.success(message)
setUsers((users) => {
users[index].nickname = nickname
})
} else {
toast.error(message)
}
}
const handleScoreChange = async (user: User, index: number) => {
let score: number
try {
const { value } = await showModal({
mode: 'prompt',
text: t('admin.newScore'),
input: user.score.toString(),
inputType: 'number',
})
score = Number.parseInt(value)
} catch {
return
}
const { code, message } = await fetch.put<fetch.ResponseBody>(
`/admin/users/${user.uid}/score`,
{ score },
)
if (code === 0) {
toast.success(message)
setUsers((users) => {
users[index].score = score
})
} else {
toast.error(message)
}
}
const handlePermissionChange = async (user: User, index: number) => {
const permissions: ModalInputProps['choices'] = [
{ text: t('admin.banned'), value: '-1' },
{ text: t('admin.normal'), value: '0' },
]
if (currentUser.permission > UserPermission.Admin) {
permissions.push({ text: t('admin.admin'), value: '1' })
}
let permission: UserPermission
try {
const { value } = await showModal({
mode: 'prompt',
text: t('admin.newPermission'),
input: user.permission.toString(),
inputType: 'radios',
choices: permissions,
})
permission = Number.parseInt(value)
} catch {
return
}
const { code, message } = await fetch.put<fetch.ResponseBody>(
`/admin/users/${user.uid}/permission`,
{ permission },
)
if (code === 0) {
toast.success(message)
setUsers((users) => {
users[index].permission = permission
})
} else {
toast.error(message)
}
}
const handleVerificationToggle = async (user: User, index: number) => {
const { code, message } = await fetch.put<fetch.ResponseBody>(
`/admin/users/${user.uid}/verification`,
)
if (code === 0) {
toast.success(message)
setUsers((users) => {
users[index].verified = !users[index].verified
})
} else {
toast.error(message)
}
}
const handlePasswordChange = async (user: User) => {
let password: string
try {
const { value } = await showModal({
mode: 'prompt',
text: t('admin.newUserPassword'),
inputType: 'password',
placeholder: t('adminchangePassword'),
})
password = value
} catch {
return
}
const { code, message } = await fetch.put<fetch.ResponseBody>(
`/admin/users/${user.uid}/password`,
{ password },
)
if (code === 0) {
toast.success(message)
} else {
toast.error(message)
}
}
const handleDelete = async (user: User) => {
try {
await showModal({
text: t('admin.deleteUserNotice'),
okButtonType: 'danger',
})
} catch {
return
}
const { code, message } = await fetch.del(`/admin/users/${user.uid}`)
if (code === 0) {
toast.success(message)
setUsers((users) => users.filter(({ uid }) => uid !== user.uid))
} else {
toast.error(message)
}
}
return (
<div className="card">
<div className={`card-header ${modeSwitchStyles.header}`}>
<form className="input-group" onSubmit={handleSubmitQuery}>
<input
type="text"
className="form-control"
title={t('vendor.datatable.search')}
value={query}
onChange={handleQueryChange}
/>
<div className="input-group-append">
<button className="btn btn-primary" type="submit">
{t('vendor.datatable.search')}
</button>
</div>
</form>
<div className="btn-group btn-group-toggle">
<label
className={`btn btn-secondary ${isTableMode ? 'active' : ''}`}
title="Table Mode"
>
<input
type="radio"
value="table"
checked={isTableMode}
onChange={handleModeChange}
/>
<i className="fas fa-list"></i>
</label>
<label
className={`btn btn-secondary ${isTableMode ? '' : 'active'}`}
title="Card Mode"
>
<input
type="radio"
value="card"
checked={!isTableMode}
onChange={handleModeChange}
/>
<i className="fas fa-grip-vertical"></i>
</label>
</div>
</div>
{isLoading ? (
<div className="card-body">
<Loading />
</div>
) : users.length === 0 ? (
<div className="card-body text-center">{t('general.noResult')}</div>
) : isTableMode ? (
<div className="card-body table-responsive p-0">
<table className="table table-striped">
<thead>
<tr>
<th>UID</th>
<th>{t('general.user.email')}</th>
<th>{t('general.user.nickname')}</th>
<th>{t('general.user.score')}</th>
<th>{t('admin.permission')}</th>
<th>{t('admin.verification')}</th>
<th>{t('general.user.register-at')}</th>
<th>{t('admin.operationsTitle')}</th>
</tr>
</thead>
<tbody>
{users.map((user, i) => (
<Row
key={user.uid}
user={user}
currentUser={currentUser}
onEmailChange={() => handleEmailChange(user, i)}
onNicknameChange={() => handleNicknameChange(user, i)}
onScoreChange={() => handleScoreChange(user, i)}
onPermissionChange={() => handlePermissionChange(user, i)}
onVerificationToggle={() => handleVerificationToggle(user, i)}
onPasswordChange={() => handlePasswordChange(user)}
onDelete={() => handleDelete(user)}
/>
))}
</tbody>
</table>
</div>
) : (
<div className="card-body d-flex flex-wrap">
{users.map((user, i) => (
<Card
key={user.uid}
user={user}
currentUser={currentUser}
onEmailChange={() => handleEmailChange(user, i)}
onNicknameChange={() => handleNicknameChange(user, i)}
onScoreChange={() => handleScoreChange(user, i)}
onPermissionChange={() => handlePermissionChange(user, i)}
onVerificationToggle={() => handleVerificationToggle(user, i)}
onPasswordChange={() => handlePasswordChange(user)}
onDelete={() => handleDelete(user)}
/>
))}
</div>
)}
<div className="card-footer">
<div className="float-right">
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</div>
</div>
</div>
)
}
export default hot(UsersManagement)

View File

@ -0,0 +1,27 @@
import { t } from '@/scripts/i18n'
import { User, UserPermission } from '@/scripts/types'
export function humanizePermission(permission: UserPermission): string {
switch (permission) {
case UserPermission.Banned:
return t('admin.banned')
case UserPermission.Normal:
return t('admin.normal')
case UserPermission.Admin:
return t('admin.admin')
case UserPermission.SuperAdmin:
return t('admin.superAdmin')
}
}
export function verificationStatusText(isVerified: boolean): string {
return isVerified ? t('admin.verified') : t('admin.unverified')
}
export function canModifyUser(target: User, current: User): boolean {
return target.uid === current.uid || current.permission > target.permission
}
export function canModifyPermission(target: User, current: User): boolean {
return current.permission > target.permission
}

View File

@ -1,216 +0,0 @@
import $ from 'jquery'
import 'bootstrap'
import { mount } from '@vue/test-utils'
import Modal from '@/components/Modal.vue'
test('id', () => {
const wrapper = mount(Modal, {
propsData: {
id: 'id',
},
})
expect(wrapper.find('#id').exists()).toBeTrue()
})
test('title', () => {
const wrapper = mount(Modal, {
propsData: {
title: 'kumiko',
},
})
expect(wrapper.find('.modal-title').text()).toBe('kumiko')
})
test('close button at header', () => {
const wrapper = mount(Modal)
wrapper.find('.modal-header > button').trigger('click')
expect(wrapper.emitted().dismiss).toHaveLength(1)
})
test('render lines', () => {
const wrapper = mount(Modal, {
propsData: {
text: 'kumiko\nreina',
},
})
const paragraphs = wrapper.findAll('p')
expect(paragraphs).toHaveLength(2)
expect(paragraphs.at(0).text()).toBe('kumiko')
expect(paragraphs.at(1).text()).toBe('reina')
})
test('dynamic html', () => {
const wrapper = mount(Modal, {
propsData: {
dangerousHTML: '<div class="eupho">kumiko</div>',
},
})
expect(wrapper.find('.eupho').text()).toBe('kumiko')
})
test('cancel by button at footer', () => {
const wrapper = mount(Modal)
wrapper.find('.btn-secondary').trigger('click')
expect(wrapper.emitted().dismiss).toHaveLength(1)
})
test('alert mode', () => {
const wrapper = mount(Modal, {
propsData: {
mode: 'alert',
},
})
expect(wrapper.find('.btn-secondary').exists()).toBeFalse()
})
test('prompt mode', () => {
const wrapper = mount(Modal, {
propsData: {
mode: 'prompt',
input: 'default-value',
},
})
expect(wrapper.find('.btn-secondary').exists()).toBeTrue()
wrapper.find('input').setValue('hazuki')
wrapper.find('.btn-primary').trigger('click')
expect(wrapper.emitted().confirm[0][0]).toStrictEqual({ value: 'hazuki' })
})
test('input placeholder', () => {
const wrapper = mount(Modal, {
propsData: {
mode: 'prompt',
placeholder: 'hibike',
},
})
expect(wrapper.find('input').attributes('placeholder')).toBe('hibike')
})
test('validate input', () => {
const stub = jest.fn()
.mockReturnValueOnce(false)
.mockReturnValueOnce('invalid')
const wrapper = mount(Modal, {
propsData: {
mode: 'prompt',
validator: stub,
},
})
const button = wrapper.find('.btn-primary')
button.trigger('click')
expect(wrapper.find('.alert').exists()).toBeFalse()
button.trigger('click')
expect(wrapper.find('.alert').text()).toContain('invalid')
})
test('input type', () => {
const wrapper = mount(Modal, {
propsData: {
mode: 'prompt',
inputType: 'password',
},
})
expect(wrapper.find('[type=password]').exists()).toBeTrue()
})
test('modal type', () => {
const wrapper = mount(Modal, {
propsData: {
type: 'danger',
},
})
expect(wrapper.find('.modal-content').classes('bg-danger')).toBeTrue()
})
test('hide header', () => {
const wrapper = mount(Modal, {
propsData: {
showHeader: false,
},
})
expect(wrapper.find('.modal-header').exists()).toBeFalse()
})
test('centered modal', () => {
const wrapper = mount(Modal, {
propsData: {
center: true,
},
})
expect(
wrapper.find('.modal-dialog').classes('modal-dialog-centered'),
).toBeTrue()
})
test('customize ok button', () => {
const wrapper = mount(Modal, {
propsData: {
okButtonText: 'OK',
okButtonType: 'danger',
},
})
const button = wrapper.find('.btn:nth-child(2)')
expect(button.text().trim()).toBe('OK')
expect(button.classes('btn-danger')).toBeTrue()
})
test('customize cancel button', () => {
const wrapper = mount(Modal, {
propsData: {
cancelButtonText: 'CANCEL',
cancelButtonType: 'danger',
},
})
const button = wrapper.find('.btn:nth-child(1)')
expect(button.text().trim()).toBe('CANCEL')
expect(button.classes('btn-danger')).toBeTrue()
})
test('flex footer', () => {
const wrapper = mount(Modal, {
propsData: {
flexFooter: true,
},
})
expect(wrapper.find('.modal-footer').classes())
.toContainValues(['d-flex', 'justify-content-between'])
})
test('default slot', () => {
const wrapper = mount(Modal, {
slots: {
default: '<div class="trumpet">reina</div>',
},
})
expect(wrapper.find('.modal-body > .trumpet').text()).toBe('reina')
})
test('footer slot', () => {
const wrapper = mount(Modal, {
slots: {
footer: '<div class="contrabass">sapphire</div>',
},
})
expect(wrapper.find('.modal-footer > .contrabass').text()).toBe('sapphire')
})
test('prevent duplicated dismission', () => {
const wrapper = mount(Modal)
wrapper.find('.btn-secondary').trigger('click')
$(wrapper.element).trigger('hide.bs.modal')
$(wrapper.element).trigger('hidden.bs.modal')
expect(wrapper.emitted().dismiss).toHaveLength(1)
wrapper.find('.btn-primary').trigger('click')
$(wrapper.element).trigger('hide.bs.modal')
$(wrapper.element).trigger('hidden.bs.modal')
expect(wrapper.emitted().dismiss).toHaveLength(1)
$(wrapper.element).trigger('hide.bs.modal')
$(wrapper.element).trigger('hidden.bs.modal')
expect(wrapper.emitted().dismiss).toHaveLength(2)
})

View File

@ -1,467 +0,0 @@
import Vue from 'vue'
import { mount } from '@vue/test-utils'
import { flushPromises } from '../../utils'
import '@/scripts/i18n'
import Modal from '@/components/Modal.vue'
import { showModal, toast } from '@/scripts/notify'
import Users from '@/views/admin/Users.vue'
jest.mock('@/scripts/i18n', () => ({
trans: (key: string) => key,
}))
jest.mock('@/scripts/notify')
test('fetch data after initializing', () => {
Vue.prototype.$http.get.mockResolvedValue({ data: [] })
mount(Users)
expect(Vue.prototype.$http.get).toBeCalledWith(
'/admin/users/list',
{
page: 1, perPage: 10, search: '', sortField: 'uid', sortType: 'asc',
},
)
})
test('humanize permission', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{ uid: 1, permission: -1 },
{ uid: 2, permission: 0 },
{ uid: 3, permission: 1 },
{ uid: 4, permission: 2 },
],
})
const wrapper = mount(Users)
await flushPromises()
const text = wrapper.find('.vgt-table').text()
expect(text).toContain('admin.banned')
expect(text).toContain('admin.normal')
expect(text).toContain('admin.admin')
expect(text).toContain('admin.superAdmin')
})
test('generate players page link', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{ uid: 1, permission: 0 },
],
})
const wrapper = mount(Users)
await flushPromises()
expect(wrapper.find('[data-toggle="tooltip"]').attributes('href')).toBe('/admin/players?uid=1')
})
test('permission option should not be displayed for super admins', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{ uid: 1, permission: 2 },
],
})
const wrapper = mount(Users)
await flushPromises()
expect(wrapper.find('[data-test=permission]').exists()).toBeFalse()
})
test('permission option should be displayed for admin as super admin', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{
uid: 1, permission: 1, operations: 2,
},
],
})
const wrapper = mount(Users)
await flushPromises()
expect(wrapper.find('[data-test=permission]').exists()).toBeTrue()
})
test('permission option should be displayed for normal users as super admin', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{
uid: 1, permission: 0, operations: 2,
},
],
})
const wrapper = mount(Users)
await flushPromises()
expect(wrapper.find('[data-test=permission]').exists()).toBeTrue()
})
test('permission option should be displayed for banned users as super admin', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{
uid: 1, permission: -1, operations: 2,
},
],
})
const wrapper = mount(Users)
await flushPromises()
expect(wrapper.find('[data-test=permission]').exists()).toBeTrue()
})
test('permission option should not be displayed for other admins as admin', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{
uid: 1, permission: 1, operations: 1,
},
],
})
const wrapper = mount(Users)
await flushPromises()
expect(wrapper.find('[data-test=permission]').exists()).toBeFalse()
})
test('permission option should be displayed for normal users as admin', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{
uid: 1, permission: 0, operations: 1,
},
],
})
const wrapper = mount(Users)
await flushPromises()
expect(wrapper.find('[data-test=permission]').exists()).toBeTrue()
})
test('permission option should be displayed for banned users as admin', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{
uid: 1, permission: -1, operations: 1,
},
],
})
const wrapper = mount(Users)
await flushPromises()
expect(wrapper.find('[data-test=permission]').exists()).toBeTrue()
})
test('deletion button should not be displayed for super admins', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{ uid: 1, permission: 2 },
],
})
const wrapper = mount(Users)
await flushPromises()
expect(wrapper.find('[data-test="deleteUser"]').attributes('disabled')).toBe('disabled')
})
test('deletion button should be displayed for admins as super admin', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{
uid: 1, permission: 1, operations: 2,
},
],
})
const wrapper = mount(Users)
await flushPromises()
expect(wrapper.find('[data-test="deleteUser"]').attributes('disabled')).toBeNil()
})
test('deletion button should be displayed for normal users as super admin', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{
uid: 1, permission: 0, operations: 2,
},
],
})
const wrapper = mount(Users)
await flushPromises()
expect(wrapper.find('[data-test="deleteUser"]').attributes('disabled')).toBeNil()
})
test('deletion button should be displayed for banned users as super admin', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{
uid: 1, permission: -1, operations: 2,
},
],
})
const wrapper = mount(Users)
await flushPromises()
expect(wrapper.find('[data-test="deleteUser"]').attributes('disabled')).toBeNil()
})
test('deletion button should not be displayed for other admins as admin', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{
uid: 1, permission: 1, operations: 1,
},
],
})
const wrapper = mount(Users)
await flushPromises()
expect(wrapper.find('[data-test="deleteUser"]').attributes('disabled')).toBe('disabled')
})
test('deletion button should be displayed for normal users as admin', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{
uid: 1, permission: 0, operations: 1,
},
],
})
const wrapper = mount(Users)
await flushPromises()
expect(wrapper.find('[data-test="deleteUser"]').attributes('disabled')).toBeNil()
})
test('deletion button should be displayed for banned users as admin', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{
uid: 1, permission: -1, operations: 1,
},
],
})
const wrapper = mount(Users)
await flushPromises()
expect(wrapper.find('[data-test="deleteUser"]').attributes('disabled')).toBeNil()
})
test('change email', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{ uid: 1, email: 'a@b.c' },
],
})
Vue.prototype.$http.post
.mockResolvedValueOnce({ code: 1, message: '1' })
.mockResolvedValueOnce({ code: 0, message: '0' })
showModal
.mockRejectedValueOnce(null)
.mockResolvedValue({ value: 'd@e.f' })
const wrapper = mount(Users)
await flushPromises()
const button = wrapper.find('[data-test="email"]')
button.trigger('click')
expect(Vue.prototype.$http.post).not.toBeCalled()
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/users?action=email',
{ uid: 1, email: 'd@e.f' },
)
expect(wrapper.text()).toContain('a@b.c')
button.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('d@e.f')
})
test('toggle verification', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{ uid: 1, verified: false },
],
})
Vue.prototype.$http.post
.mockResolvedValueOnce({ code: 1, message: '1' })
.mockResolvedValueOnce({ code: 0, message: '0' })
const wrapper = mount(Users)
await flushPromises()
const button = wrapper.find('[data-test="verification"')
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/users?action=verification',
{ uid: 1 },
)
button.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('admin.verified')
})
test('change nickname', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{ uid: 1, nickname: 'old' },
],
})
Vue.prototype.$http.post
.mockResolvedValueOnce({ code: 1, message: '1' })
.mockResolvedValueOnce({ code: 0, message: '0' })
showModal
.mockRejectedValueOnce(null)
.mockResolvedValue({ value: 'new' })
const wrapper = mount(Users)
await flushPromises()
const button = wrapper.find('[data-test="nickname"]')
button.trigger('click')
expect(Vue.prototype.$http.post).not.toBeCalled()
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/users?action=nickname',
{ uid: 1, nickname: 'new' },
)
expect(wrapper.text()).toContain('old')
button.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('new')
})
test('change password', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{ uid: 1 },
],
})
Vue.prototype.$http.post
.mockResolvedValueOnce({ code: 0, message: '0' })
.mockResolvedValueOnce({ code: 1, message: '1' })
showModal
.mockRejectedValueOnce(null)
.mockResolvedValue({ value: 'password' })
const wrapper = mount(Users)
await flushPromises()
const button = wrapper.find('.btn-default')
button.trigger('click')
expect(Vue.prototype.$http.post).not.toBeCalled()
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/users?action=password',
{ uid: 1, password: 'password' },
)
await flushPromises()
expect(toast.success).toBeCalledWith('0')
button.trigger('click')
await flushPromises()
expect(toast.error).toBeCalledWith('1')
})
test('change score', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{ uid: 1, score: 23 },
],
})
Vue.prototype.$http.post
.mockResolvedValueOnce({ code: 1, message: '1' })
.mockResolvedValueOnce({ code: 0, message: '0' })
showModal
.mockRejectedValueOnce(null)
.mockResolvedValue({ value: '45' })
const wrapper = mount(Users)
await flushPromises()
const button = wrapper.find('[data-test="score"]')
button.trigger('click')
expect(Vue.prototype.$http.post).not.toBeCalled()
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/users?action=score',
{ uid: 1, score: 45 },
)
expect(wrapper.text()).toContain('23')
button.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('45')
})
test('change permission', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{
uid: 1, permission: 0, operations: 2,
},
{
uid: 2, permission: 0, operations: 1,
},
],
})
Vue.prototype.$http.post
.mockResolvedValueOnce({ code: 1, message: '1' })
.mockResolvedValue({ code: 0, message: '0' })
const wrapper = mount(Users)
await flushPromises()
wrapper
.findAll('[data-test=permission]')
.at(0)
.trigger('click')
expect(wrapper.findAll('[type=radio]')).toHaveLength(3)
wrapper
.findAll('[data-test=permission]')
.at(1)
.trigger('click')
expect(wrapper.findAll('[type=radio]')).toHaveLength(2)
const button = wrapper.findAll('[data-test=permission]').at(1)
button.trigger('click')
wrapper.find('[type=radio]:nth-child(1)').setChecked()
wrapper.find(Modal).vm.$emit('confirm')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/users?action=permission',
{ uid: 2, permission: -1 },
)
button.trigger('click')
wrapper.find('[type=radio]:nth-child(1)').setChecked()
wrapper.find(Modal).vm.$emit('confirm')
await flushPromises()
expect(wrapper.text()).toContain('admin.banned')
})
test('delete user', async () => {
Vue.prototype.$http.get.mockResolvedValue({
data: [
{ uid: 1, nickname: 'to-be-deleted' },
],
})
Vue.prototype.$http.post
.mockResolvedValueOnce({ code: 1, message: '1' })
.mockResolvedValue({ code: 0, message: '0' })
showModal
.mockRejectedValueOnce(null)
.mockResolvedValue({ value: '' })
const wrapper = mount(Users)
await flushPromises()
const button = wrapper.find('.btn-danger')
button.trigger('click')
expect(Vue.prototype.$http.post).not.toBeCalled()
button.trigger('click')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/admin/users?action=delete',
{ uid: 1 },
)
expect(wrapper.text()).toContain('to-be-deleted')
button.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('No data')
})

View File

@ -0,0 +1,798 @@
import React from 'react'
import { render, waitFor, fireEvent } from '@testing-library/react'
import { createPaginator } from '../../utils'
import { t } from '@/scripts/i18n'
import * as fetch from '@/scripts/net'
import { User, UserPermission } from '@/scripts/types'
import UsersManagement from '@/views/admin/UsersManagement'
jest.mock('@/scripts/net')
const fixture: Readonly<User> = Object.freeze<User>({
uid: 1,
email: 'a@b.c',
nickname: 'abc',
score: 1000,
avatar: 0,
permission: UserPermission.Normal,
ip: '::1',
last_sign_at: new Date().toString(),
register_at: new Date().toString(),
verified: true,
})
beforeAll(() => {
Object.assign(window, { innerWidth: 500 })
})
afterAll(() => {
Object.assign(window, { innerWidth: 1024 })
})
beforeEach(() => {
window.blessing.extra = {
currentUser: { ...fixture, uid: 2, permission: UserPermission.Admin },
}
})
test('search users', async () => {
fetch.get.mockResolvedValue(createPaginator([]))
const { getByTitle, getByText } = render(<UsersManagement />)
await waitFor(() =>
expect(fetch.get).toBeCalledWith('/admin/users/list', { q: '', page: 1 }),
)
fireEvent.input(getByTitle(t('vendor.datatable.search')), {
target: { value: 's' },
})
fireEvent.click(getByText(t('vendor.datatable.search')))
await waitFor(() =>
expect(fetch.get).toBeCalledWith('/admin/users/list', {
q: 's',
page: 1,
}),
)
})
describe('access control', () => {
describe('current user is super administrator', () => {
beforeEach(() => {
window.blessing.extra = {
currentUser: {
...fixture,
uid: 2,
permission: UserPermission.SuperAdmin,
},
}
})
it('target user is super administrator', async () => {
fetch.get.mockResolvedValue(
createPaginator([
{ ...fixture, permission: UserPermission.SuperAdmin },
]),
)
const { getByTitle, getByText, queryByText, queryByTitle } = render(
<UsersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
expect(queryByText(t('admin.changeEmail'))).not.toBeInTheDocument()
expect(queryByText(t('admin.changeNickName'))).not.toBeInTheDocument()
expect(queryByText(t('admin.changePassword'))).not.toBeInTheDocument()
expect(queryByText(t('admin.changeScore'))).not.toBeInTheDocument()
expect(queryByText(t('admin.changePermission'))).not.toBeInTheDocument()
expect(queryByText(t('admin.toggleVerification'))).not.toBeInTheDocument()
expect(queryByText(t('admin.deleteUser'))).not.toBeInTheDocument()
fireEvent.click(getByTitle('Table Mode'))
expect(queryByTitle(t('admin.changeEmail'))).not.toBeInTheDocument()
expect(queryByTitle(t('admin.changeNickName'))).not.toBeInTheDocument()
expect(queryByTitle(t('admin.changeScore'))).not.toBeInTheDocument()
expect(queryByTitle(t('admin.changePermission'))).not.toBeInTheDocument()
expect(
queryByTitle(t('admin.toggleVerification')),
).not.toBeInTheDocument()
expect(getByText(t('admin.changePassword'))).toBeDisabled()
expect(getByText(t('admin.deleteUser'))).toBeDisabled()
})
it('target user is normal administrator', async () => {
fetch.get.mockResolvedValue(
createPaginator([{ ...fixture, permission: UserPermission.Admin }]),
)
const { getByTitle, getByText, queryByText, queryByTitle } = render(
<UsersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
expect(queryByText(t('admin.changeEmail'))).toBeInTheDocument()
expect(queryByText(t('admin.changeNickName'))).toBeInTheDocument()
expect(queryByText(t('admin.changePassword'))).toBeInTheDocument()
expect(queryByText(t('admin.changeScore'))).toBeInTheDocument()
expect(queryByText(t('admin.changePermission'))).toBeInTheDocument()
expect(queryByText(t('admin.toggleVerification'))).toBeInTheDocument()
expect(queryByText(t('admin.deleteUser'))).toBeInTheDocument()
fireEvent.click(getByTitle('Table Mode'))
expect(queryByTitle(t('admin.changeEmail'))).toBeInTheDocument()
expect(queryByTitle(t('admin.changeNickName'))).toBeInTheDocument()
expect(queryByTitle(t('admin.changeScore'))).toBeInTheDocument()
expect(queryByTitle(t('admin.changePermission'))).toBeInTheDocument()
expect(queryByTitle(t('admin.toggleVerification'))).toBeInTheDocument()
expect(getByText(t('admin.changePassword'))).toBeEnabled()
expect(getByText(t('admin.deleteUser'))).toBeEnabled()
})
})
describe('current user is normal administrator', () => {
beforeEach(() => {
window.blessing.extra = {
currentUser: {
...fixture,
uid: 2,
permission: UserPermission.Admin,
},
}
})
it('target user is super administrator', async () => {
fetch.get.mockResolvedValue(
createPaginator([
{ ...fixture, permission: UserPermission.SuperAdmin },
]),
)
const { getByTitle, getByText, queryByText, queryByTitle } = render(
<UsersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
expect(queryByText(t('admin.changeEmail'))).not.toBeInTheDocument()
expect(queryByText(t('admin.changeNickName'))).not.toBeInTheDocument()
expect(queryByText(t('admin.changePassword'))).not.toBeInTheDocument()
expect(queryByText(t('admin.changeScore'))).not.toBeInTheDocument()
expect(queryByText(t('admin.changePermission'))).not.toBeInTheDocument()
expect(queryByText(t('admin.toggleVerification'))).not.toBeInTheDocument()
expect(queryByText(t('admin.deleteUser'))).not.toBeInTheDocument()
fireEvent.click(getByTitle('Table Mode'))
expect(queryByTitle(t('admin.changeEmail'))).not.toBeInTheDocument()
expect(queryByTitle(t('admin.changeNickName'))).not.toBeInTheDocument()
expect(queryByTitle(t('admin.changeScore'))).not.toBeInTheDocument()
expect(queryByTitle(t('admin.changePermission'))).not.toBeInTheDocument()
expect(
queryByTitle(t('admin.toggleVerification')),
).not.toBeInTheDocument()
expect(getByText(t('admin.changePassword'))).toBeDisabled()
expect(getByText(t('admin.deleteUser'))).toBeDisabled()
})
it('target user is normal administrator', async () => {
fetch.get.mockResolvedValue(
createPaginator([{ ...fixture, permission: UserPermission.Admin }]),
)
const { getByTitle, getByText, queryByText, queryByTitle } = render(
<UsersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
expect(queryByText(t('admin.changeEmail'))).not.toBeInTheDocument()
expect(queryByText(t('admin.changeNickName'))).not.toBeInTheDocument()
expect(queryByText(t('admin.changePassword'))).not.toBeInTheDocument()
expect(queryByText(t('admin.changeScore'))).not.toBeInTheDocument()
expect(queryByText(t('admin.changePermission'))).not.toBeInTheDocument()
expect(queryByText(t('admin.toggleVerification'))).not.toBeInTheDocument()
expect(queryByText(t('admin.deleteUser'))).not.toBeInTheDocument()
fireEvent.click(getByTitle('Table Mode'))
expect(queryByTitle(t('admin.changeEmail'))).not.toBeInTheDocument()
expect(queryByTitle(t('admin.changeNickName'))).not.toBeInTheDocument()
expect(queryByTitle(t('admin.changeScore'))).not.toBeInTheDocument()
expect(queryByTitle(t('admin.changePermission'))).not.toBeInTheDocument()
expect(
queryByTitle(t('admin.toggleVerification')),
).not.toBeInTheDocument()
expect(getByText(t('admin.changePassword'))).toBeDisabled()
expect(getByText(t('admin.deleteUser'))).toBeDisabled()
})
})
it('current user and target user are self', async () => {
const user = {
...fixture,
permission: UserPermission.Admin,
}
window.blessing.extra = { currentUser: user }
fetch.get.mockResolvedValue(createPaginator([user]))
const { getByTitle, getByText, queryByText, queryByTitle } = render(
<UsersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
expect(queryByText(t('admin.changeEmail'))).toBeInTheDocument()
expect(queryByText(t('admin.changeNickName'))).toBeInTheDocument()
expect(queryByText(t('admin.changePassword'))).toBeInTheDocument()
expect(queryByText(t('admin.changeScore'))).toBeInTheDocument()
expect(queryByText(t('admin.changePermission'))).not.toBeInTheDocument()
expect(queryByText(t('admin.toggleVerification'))).toBeInTheDocument()
expect(queryByText(t('admin.deleteUser'))).toBeInTheDocument()
fireEvent.click(getByTitle('Table Mode'))
expect(queryByTitle(t('admin.changeEmail'))).toBeInTheDocument()
expect(queryByTitle(t('admin.changeNickName'))).toBeInTheDocument()
expect(queryByTitle(t('admin.changeScore'))).toBeInTheDocument()
expect(queryByTitle(t('admin.changePermission'))).not.toBeInTheDocument()
expect(queryByTitle(t('admin.toggleVerification'))).toBeInTheDocument()
expect(getByText(t('admin.changePassword'))).toBeEnabled()
expect(getByText(t('admin.deleteUser'))).toBeEnabled()
})
})
describe('update email', () => {
beforeEach(() => {
fetch.get.mockResolvedValue(createPaginator([fixture]))
})
it('empty value', async () => {
const { getByText, getByDisplayValue, queryByText } = render(
<UsersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changeEmail')))
fireEvent.input(getByDisplayValue(fixture.email), {
target: { value: '' },
})
fireEvent.click(getByText(t('general.confirm')))
expect(queryByText(t('auth.emptyEmail'))).toBeInTheDocument()
fireEvent.click(getByText(t('general.cancel')))
expect(fetch.put).not.toBeCalled()
expect(queryByText(fixture.email)).toBeInTheDocument()
})
it('succeeded', async () => {
fetch.put.mockResolvedValue({ code: 0, message: 'ok' })
const { getByText, getByDisplayValue, queryByText, queryByRole } = render(
<UsersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changeEmail')))
fireEvent.input(getByDisplayValue(fixture.email), {
target: { value: 'd@e.f' },
})
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(`/admin/users/${fixture.uid}/email`, {
email: 'd@e.f',
}),
)
expect(queryByText('ok')).toBeInTheDocument()
expect(queryByRole('status')).toHaveClass('alert-success')
expect(queryByText('d@e.f')).toBeInTheDocument()
})
it('failed', async () => {
fetch.put.mockResolvedValue({ code: 1, message: 'failed' })
const { getByText, getByDisplayValue, queryByText, queryByRole } = render(
<UsersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changeEmail')))
fireEvent.input(getByDisplayValue(fixture.email), {
target: { value: 'd@e.f' },
})
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(`/admin/users/${fixture.uid}/email`, {
email: 'd@e.f',
}),
)
expect(queryByText('failed')).toBeInTheDocument()
expect(queryByRole('alert')).toHaveClass('alert-danger')
expect(queryByText(fixture.email)).toBeInTheDocument()
})
})
describe('update nickname', () => {
beforeEach(() => {
fetch.get.mockResolvedValue(createPaginator([fixture]))
})
it('empty value', async () => {
const { getByText, getByDisplayValue, queryByText } = render(
<UsersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changeNickName')))
fireEvent.input(getByDisplayValue(fixture.nickname), {
target: { value: '' },
})
fireEvent.click(getByText(t('general.confirm')))
expect(queryByText(t('auth.emptyNickname'))).toBeInTheDocument()
fireEvent.click(getByText(t('general.cancel')))
expect(fetch.put).not.toBeCalled()
expect(queryByText(fixture.nickname)).toBeInTheDocument()
})
it('succeeded', async () => {
fetch.put.mockResolvedValue({ code: 0, message: 'ok' })
const { getByText, getByDisplayValue, queryByText, queryByRole } = render(
<UsersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changeNickName')))
fireEvent.input(getByDisplayValue(fixture.nickname), {
target: { value: 'kumiko' },
})
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(`/admin/users/${fixture.uid}/nickname`, {
nickname: 'kumiko',
}),
)
expect(queryByText('ok')).toBeInTheDocument()
expect(queryByRole('status')).toHaveClass('alert-success')
expect(queryByText('kumiko')).toBeInTheDocument()
})
it('failed', async () => {
fetch.put.mockResolvedValue({ code: 1, message: 'failed' })
const { getByText, getByDisplayValue, queryByText, queryByRole } = render(
<UsersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changeNickName')))
fireEvent.input(getByDisplayValue(fixture.nickname), {
target: { value: 'kumiko' },
})
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(`/admin/users/${fixture.uid}/nickname`, {
nickname: 'kumiko',
}),
)
expect(queryByText('failed')).toBeInTheDocument()
expect(queryByRole('alert')).toHaveClass('alert-danger')
expect(queryByText(fixture.nickname)).toBeInTheDocument()
})
})
describe('update score', () => {
beforeEach(() => {
fetch.get.mockResolvedValue(createPaginator([fixture]))
})
it('cancelled', async () => {
const { getByText, queryByText } = render(<UsersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changeScore')))
fireEvent.click(getByText(t('general.cancel')))
expect(fetch.put).not.toBeCalled()
expect(queryByText(fixture.score.toString())).toBeInTheDocument()
})
it('succeeded', async () => {
fetch.put.mockResolvedValue({ code: 0, message: 'ok' })
const { getByText, getByDisplayValue, queryByText, queryByRole } = render(
<UsersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changeScore')))
fireEvent.input(getByDisplayValue(fixture.score.toString()), {
target: { value: '999' },
})
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(`/admin/users/${fixture.uid}/score`, {
score: 999,
}),
)
expect(queryByText('ok')).toBeInTheDocument()
expect(queryByRole('status')).toHaveClass('alert-success')
expect(queryByText('999')).toBeInTheDocument()
})
it('failed', async () => {
fetch.put.mockResolvedValue({ code: 1, message: 'failed' })
const { getByText, getByDisplayValue, queryByText, queryByRole } = render(
<UsersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changeScore')))
fireEvent.input(getByDisplayValue(fixture.score.toString()), {
target: { value: '999' },
})
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(`/admin/users/${fixture.uid}/score`, {
score: 999,
}),
)
expect(queryByText('failed')).toBeInTheDocument()
expect(queryByRole('alert')).toHaveClass('alert-danger')
expect(queryByText(fixture.score.toString())).toBeInTheDocument()
})
})
describe('update permission', () => {
beforeEach(() => {
fetch.get.mockResolvedValue(createPaginator([fixture]))
})
it('cancelled', async () => {
const { getByText, queryByText } = render(<UsersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changePermission')))
fireEvent.click(getByText(t('general.cancel')))
expect(fetch.put).not.toBeCalled()
expect(queryByText(t('admin.normal'))).toBeInTheDocument()
})
it('succeeded', async () => {
fetch.put.mockResolvedValue({ code: 0, message: 'ok' })
const { getByText, getByLabelText, queryByText, queryByRole } = render(
<UsersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changePermission')))
fireEvent.click(getByLabelText(t('admin.banned')))
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(
`/admin/users/${fixture.uid}/permission`,
{
permission: UserPermission.Banned,
},
),
)
expect(queryByText('ok')).toBeInTheDocument()
expect(queryByRole('status')).toHaveClass('alert-success')
expect(queryByText(t('admin.banned'))).toBeInTheDocument()
})
it('failed', async () => {
fetch.put.mockResolvedValue({ code: 1, message: 'failed' })
const { getByText, getByLabelText, queryByText, queryByRole } = render(
<UsersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changePermission')))
fireEvent.click(getByLabelText(t('admin.banned')))
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(
`/admin/users/${fixture.uid}/permission`,
{
permission: UserPermission.Banned,
},
),
)
expect(queryByText('failed')).toBeInTheDocument()
expect(queryByRole('alert')).toHaveClass('alert-danger')
expect(queryByText(t('admin.normal'))).toBeInTheDocument()
})
it('set as administrator', async () => {
window.blessing.extra = {
currentUser: {
...fixture,
uid: 2,
permission: UserPermission.SuperAdmin,
},
}
fetch.put.mockResolvedValue({ code: 0, message: 'ok' })
const { getByText, getByLabelText, queryByText, queryByRole } = render(
<UsersManagement />,
)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changePermission')))
fireEvent.click(getByLabelText(t('admin.admin')))
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(
`/admin/users/${fixture.uid}/permission`,
{
permission: UserPermission.Admin,
},
),
)
expect(queryByText('ok')).toBeInTheDocument()
expect(queryByRole('status')).toHaveClass('alert-success')
expect(queryByText(t('admin.admin'))).toBeInTheDocument()
})
})
describe('toggle verification', () => {
beforeEach(() => {
fetch.get.mockResolvedValue(createPaginator([fixture]))
})
it('succeeded', async () => {
fetch.put.mockResolvedValue({ code: 0, message: 'ok' })
const { getByText, queryByText, queryByRole } = render(<UsersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.toggleVerification')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(
`/admin/users/${fixture.uid}/verification`,
),
)
expect(queryByText('ok')).toBeInTheDocument()
expect(queryByRole('status')).toHaveClass('alert-success')
expect(queryByText(t('admin.unverified'))).toBeInTheDocument()
})
it('failed', async () => {
fetch.put.mockResolvedValue({ code: 1, message: 'failed' })
const { getByText, queryByText, queryByRole } = render(<UsersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.toggleVerification')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(
`/admin/users/${fixture.uid}/verification`,
),
)
expect(queryByText('failed')).toBeInTheDocument()
expect(queryByRole('alert')).toHaveClass('alert-danger')
expect(queryByText(t('admin.verified'))).toBeInTheDocument()
})
})
describe('update password', () => {
beforeEach(() => {
fetch.get.mockResolvedValue(createPaginator([fixture]))
})
it('cancelled', async () => {
const { getByText, queryByText } = render(<UsersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changePassword')))
fireEvent.click(getByText(t('general.cancel')))
expect(fetch.put).not.toBeCalled()
expect(queryByText(fixture.score.toString())).toBeInTheDocument()
})
it('succeeded', async () => {
fetch.put.mockResolvedValue({ code: 0, message: 'ok' })
const {
getByText,
getByPlaceholderText,
queryByText,
queryByRole,
} = render(<UsersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changePassword')))
fireEvent.input(getByPlaceholderText(t('adminchangePassword')), {
target: { value: '123' },
})
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(`/admin/users/${fixture.uid}/password`, {
password: '123',
}),
)
expect(queryByText('ok')).toBeInTheDocument()
expect(queryByRole('status')).toHaveClass('alert-success')
})
it('failed', async () => {
fetch.put.mockResolvedValue({ code: 1, message: 'failed' })
const {
getByText,
getByPlaceholderText,
queryByText,
queryByRole,
} = render(<UsersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.changePassword')))
fireEvent.input(getByPlaceholderText(t('adminchangePassword')), {
target: { value: '123' },
})
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(`/admin/users/${fixture.uid}/password`, {
password: '123',
}),
)
expect(queryByText('failed')).toBeInTheDocument()
expect(queryByRole('alert')).toHaveClass('alert-danger')
})
})
describe('delete user', () => {
beforeEach(() => {
fetch.get.mockResolvedValue(createPaginator([fixture]))
})
it('cancelled', async () => {
const { getByText, queryByText } = render(<UsersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.deleteUser')))
fireEvent.click(getByText(t('general.cancel')))
expect(fetch.del).not.toBeCalled()
expect(queryByText(fixture.email)).toBeInTheDocument()
})
it('succeeded', async () => {
fetch.del.mockResolvedValue({ code: 0, message: 'ok' })
const { getByText, queryByText, queryByRole } = render(<UsersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.deleteUser')))
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.del).toBeCalledWith(`/admin/users/${fixture.uid}`),
)
expect(queryByText('ok')).toBeInTheDocument()
expect(queryByRole('status')).toHaveClass('alert-success')
expect(queryByText(fixture.email)).not.toBeInTheDocument()
})
it('failed', async () => {
fetch.del.mockResolvedValue({ code: 1, message: 'failed' })
const { getByText, queryByText, queryByRole } = render(<UsersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByText(t('admin.deleteUser')))
fireEvent.click(getByText(t('general.confirm')))
await waitFor(() =>
expect(fetch.del).toBeCalledWith(`/admin/users/${fixture.uid}`),
)
expect(queryByText('failed')).toBeInTheDocument()
expect(queryByRole('alert')).toHaveClass('alert-danger')
expect(queryByText(fixture.email)).toBeInTheDocument()
})
})
describe('table mode', () => {
beforeEach(() => {
fetch.get.mockResolvedValue(createPaginator([fixture]))
})
it('large screen', async () => {
Object.assign(window, { innerWidth: 1024 })
const { queryByText } = render(<UsersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
expect(queryByText(t('admin.operationsTitle'))).toBeInTheDocument()
Object.assign(window, { innerWidth: 500 })
})
it('update email', async () => {
const { getByText, getByTitle, queryByText } = render(<UsersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByTitle('Table Mode'))
fireEvent.click(getByTitle(t('admin.changeEmail')))
fireEvent.click(getByText(t('general.cancel')))
expect(fetch.put).not.toBeCalled()
expect(queryByText(fixture.email)).toBeInTheDocument()
})
it('update nickname', async () => {
const { getByText, getByTitle, queryByText } = render(<UsersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByTitle('Table Mode'))
fireEvent.click(getByTitle(t('admin.changeNickName')))
fireEvent.click(getByText(t('general.cancel')))
expect(fetch.put).not.toBeCalled()
expect(queryByText(fixture.nickname)).toBeInTheDocument()
})
it('update score', async () => {
const { getByText, getByTitle, queryByText } = render(<UsersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByTitle('Table Mode'))
fireEvent.click(getByTitle(t('admin.changeScore')))
fireEvent.click(getByText(t('general.cancel')))
expect(fetch.put).not.toBeCalled()
expect(queryByText(fixture.score.toString())).toBeInTheDocument()
})
it('update permission', async () => {
const { getByText, getByTitle, queryByText } = render(<UsersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByTitle('Table Mode'))
fireEvent.click(getByTitle(t('admin.changePermission')))
fireEvent.click(getByText(t('general.cancel')))
expect(fetch.put).not.toBeCalled()
expect(queryByText(t('admin.normal'))).toBeInTheDocument()
})
it('toggle verification', async () => {
fetch.put.mockResolvedValue({ code: 0, message: 'ok' })
const { getByTitle, queryByText, queryByRole } = render(<UsersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByTitle('Table Mode'))
fireEvent.click(getByTitle(t('admin.toggleVerification')))
await waitFor(() =>
expect(fetch.put).toBeCalledWith(
`/admin/users/${fixture.uid}/verification`,
),
)
expect(queryByText('ok')).toBeInTheDocument()
expect(queryByRole('status')).toHaveClass('alert-success')
expect(queryByText(t('admin.unverified'))).toBeInTheDocument()
})
it('update password', async () => {
const { getByText, getByTitle, queryByText } = render(<UsersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByTitle('Table Mode'))
fireEvent.click(getByText(t('admin.changePassword')))
fireEvent.click(getByText(t('general.cancel')))
expect(fetch.put).not.toBeCalled()
expect(queryByText(fixture.score.toString())).toBeInTheDocument()
})
it('delete user', async () => {
const { getByText, getByTitle, queryByText } = render(<UsersManagement />)
await waitFor(() => expect(fetch.get).toBeCalled())
fireEvent.click(getByTitle('Table Mode'))
fireEvent.click(getByText(t('admin.deleteUser')))
fireEvent.click(getByText(t('general.cancel')))
expect(fetch.del).not.toBeCalled()
expect(queryByText(fixture.email)).toBeInTheDocument()
})
})

View File

@ -72,6 +72,7 @@
- Fixed that button "See My Upload" existed when user isn't authenticated.
- Fixed potential "Invalid Signature" issue.
- Fixed that duplicated player name is not detected when updating player name in administration panel.
- Fixed that normal administrator can set other user as administrator.
## Removed

View File

@ -72,6 +72,7 @@
- 修复未登录的用户在浏览皮肤库时出现「我的上传」按钮问题
- 修复可能的「Invalid Signature」问题
- 修复在管理面板中修改角色名时不检测角色名是否重复的问题
- 修复普通管理员可设置其他用户为管理员的问题
## 移除

View File

@ -1,3 +1,10 @@
{% extends 'admin.base' %}
{% block title %}{{ trans('general.user-manage') }}{% endblock %}
{% block before_foot %}
{% set extra = {'currentUser': auth_user()} %}
<script>
blessing.extra = {{ extra|json_encode|raw }}
</script>
{% endblock %}

View File

@ -34,6 +34,19 @@ Route::prefix('closet')->middleware('auth:jwt,oauth')->group(function () {
Route::prefix('admin')
->middleware(['auth:jwt,oauth', 'role:admin'])
->group(function () {
Route::prefix('users')->group(function () {
Route::get('', 'UsersManagementController@list')->name('list');
Route::prefix('{user}')->group(function () {
Route::put('email', 'UsersManagementController@email')->name('email');
Route::put('verification', 'UsersManagementController@verification')->name('verification');
Route::put('nickname', 'UsersManagementController@nickname')->name('nickname');
Route::put('password', 'UsersManagementController@password')->name('password');
Route::put('score', 'UsersManagementController@score')->name('score');
Route::put('permission', 'UsersManagementController@permission')->name('permission');
Route::delete('', 'UsersManagementController@delete')->name('delete');
});
});
Route::prefix('players')->group(function () {
Route::get('', 'PlayersManagementController@list');
Route::put('{player}/name', 'PlayersManagementController@name');

View File

@ -127,22 +127,30 @@ Route::prefix('admin')
Route::get('status', 'AdminController@status');
Route::prefix('users')->group(function () {
Route::prefix('users')->name('users.')->group(function () {
Route::view('', 'admin.users');
Route::post('', 'AdminController@userAjaxHandler');
Route::get('list', 'AdminController@getUserData');
Route::get('list', 'UsersManagementController@list')->name('list');
Route::prefix('{user}')->group(function () {
Route::put('email', 'UsersManagementController@email')->name('email');
Route::put('verification', 'UsersManagementController@verification')->name('verification');
Route::put('nickname', 'UsersManagementController@nickname')->name('nickname');
Route::put('password', 'UsersManagementController@password')->name('password');
Route::put('score', 'UsersManagementController@score')->name('score');
Route::put('permission', 'UsersManagementController@permission')->name('permission');
Route::delete('', 'UsersManagementController@delete')->name('delete');
});
});
Route::prefix('players')->name('players.')->group(function () {
Route::view('', 'admin.players');
Route::get('list', 'PlayersManagementController@list')->name('list');
Route::view('', 'admin.players');
Route::get('list', 'PlayersManagementController@list')->name('list');
Route::prefix('{player}')->group(function () {
Route::put('name', 'PlayersManagementController@name')->name('name');
Route::put('owner', 'PlayersManagementController@owner')->name('owner');
Route::put('textures', 'PlayersManagementController@texture')->name('texture');
Route::delete('', 'PlayersManagementController@delete')->name('delete');
});
});
});
Route::prefix('closet')->group(function () {
Route::post('{uid}', 'ClosetManagementController@add');

View File

@ -6,7 +6,6 @@ use App\Models\Texture;
use App\Models\User;
use App\Services\Plugin;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Str;
class AdminControllerTest extends TestCase
{
@ -57,215 +56,4 @@ class AdminControllerTest extends TestCase
->assertSee('0.0.0');
$filter->assertApplied('grid:admin.status');
}
public function testUsers()
{
$this->get('/admin/users')->assertSee(trans('general.user-manage'));
}
public function testGetUserData()
{
$this->getJson('/admin/users/list')
->assertJsonStructure([
'data' => [[
'uid',
'email',
'nickname',
'score',
'permission',
'register_at',
'operations',
'players_count',
]],
]);
$user = factory(User::class)->create();
$this->getJson('/admin/users/list?uid='.$user->uid)
->assertJson([
'data' => [[
'uid' => $user->uid,
'email' => $user->email,
'nickname' => $user->nickname,
'score' => $user->score,
'permission' => $user->permission,
'players_count' => 0,
]],
]);
}
public function testUserAjaxHandler()
{
// Operate on an not-existed user
$this->postJson('/admin/users')
->assertJson([
'code' => 1,
'message' => trans('admin.users.operations.non-existent'),
]);
$user = factory(User::class)->create();
// Operate without `action` field
$this->postJson('/admin/users', ['uid' => $user->uid])
->assertJson([
'code' => 1,
'message' => trans('admin.users.operations.invalid'),
]);
// An admin operating on a super admin should be forbidden
$superAdmin = factory(User::class)->states('superAdmin')->create();
$this->postJson('/admin/users', ['uid' => $superAdmin->uid])
->assertJson([
'code' => 1,
'message' => trans('admin.users.operations.no-permission'),
]);
// Action is `email` but without `email` field
$this->postJson(
'/admin/users',
['uid' => $user->uid, 'action' => 'email']
)->assertJsonValidationErrors(['email']);
// Action is `email` but with an invalid email address
$this->postJson(
'/admin/users',
['uid' => $user->uid, 'action' => 'email', 'email' => 'invalid']
)->assertJsonValidationErrors(['email']);
// Using an existed email address
$this->postJson(
'/admin/users',
['uid' => $user->uid, 'action' => 'email', 'email' => $superAdmin->email]
)->assertJson([
'code' => 1,
'message' => trans('admin.users.operations.email.existed', ['email' => $superAdmin->email]),
]);
// Set email successfully
$this->postJson(
'/admin/users',
['uid' => $user->uid, 'action' => 'email', 'email' => 'a@b.c']
)->assertJson([
'code' => 0,
'message' => trans('admin.users.operations.email.success'),
]);
$this->assertDatabaseHas('users', [
'uid' => $user->uid,
'email' => 'a@b.c',
]);
// Toggle verification
$this->postJson(
'/admin/users',
['uid' => $user->uid, 'action' => 'verification']
)->assertJson([
'code' => 0,
'message' => trans('admin.users.operations.verification.success'),
]);
$this->assertDatabaseHas('users', [
'uid' => $user->uid,
'verified' => 0,
]);
// Action is `nickname` but without `nickname` field
$this->postJson(
'/admin/users',
['uid' => $user->uid, 'action' => 'nickname']
)->assertJsonValidationErrors(['nickname']);
// Set nickname successfully
$this->postJson(
'/admin/users',
['uid' => $user->uid, 'action' => 'nickname', 'nickname' => 'nickname']
)->assertJson([
'code' => 0,
'message' => trans('admin.users.operations.nickname.success', ['new' => 'nickname']),
]);
$this->assertDatabaseHas('users', [
'uid' => $user->uid,
'nickname' => 'nickname',
]);
// Action is `password` but without `password` field
$this->postJson(
'/admin/users',
['uid' => $user->uid, 'action' => 'password']
)->assertJsonValidationErrors(['password']);
// Set a too short password
$this->postJson(
'/admin/users',
['uid' => $user->uid, 'action' => 'password', 'password' => '1']
)->assertJsonValidationErrors(['password']);
// Set a too long password
$this->postJson(
'/admin/users',
['uid' => $user->uid, 'action' => 'password', 'password' => Str::random(17)]
)->assertJsonValidationErrors(['password']);
// Set password successfully
$this->postJson(
'/admin/users',
['uid' => $user->uid, 'action' => 'password', 'password' => '12345678']
)->assertJson([
'code' => 0,
'message' => trans('admin.users.operations.password.success'),
]);
$user = User::find($user->uid);
$this->assertTrue($user->verifyPassword('12345678'));
// Action is `score` but without `score` field
$this->postJson(
'/admin/users',
['uid' => $user->uid, 'action' => 'score']
)->assertJsonValidationErrors(['score']);
// Action is `score` but with an not-an-integer value
$this->postJson(
'/admin/users',
['uid' => $user->uid, 'action' => 'score', 'score' => 'string']
)->assertJsonValidationErrors(['score']);
// Set score successfully
$this->postJson(
'/admin/users',
['uid' => $user->uid, 'action' => 'score', 'score' => 123]
)->assertJson([
'code' => 0,
'message' => trans('admin.users.operations.score.success'),
]);
$this->assertDatabaseHas('users', [
'uid' => $user->uid,
'score' => 123,
]);
// Invalid permission value
$this->postJson('/admin/users', [
'uid' => $user->uid,
'action' => 'permission',
'permission' => -2,
])->assertJsonValidationErrors(['permission']);
$user = User::find($user->uid);
$this->assertEquals(User::NORMAL, $user->permission);
// Update permission successfully
$this->postJson('/admin/users', [
'uid' => $user->uid,
'action' => 'permission',
'permission' => -1,
])->assertJson([
'code' => 0,
'message' => trans('admin.users.operations.permission'),
]);
$user = User::find($user->uid);
$this->assertEquals(User::BANNED, $user->permission);
// Delete a user
$this->postJson('/admin/users', ['uid' => $user->uid, 'action' => 'delete'])
->assertJson([
'code' => 0,
'message' => trans('admin.users.operations.delete.success'),
]);
$this->assertNull(User::find($user->uid));
}
}

View File

@ -0,0 +1,317 @@
<?php
namespace Tests;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
class UsersManagementControllerTest extends TestCase
{
use DatabaseTransactions;
protected function setUp(): void
{
parent::setUp();
$this->actingAs(factory(User::class)->states('admin')->create());
}
public function testList()
{
$user = factory(User::class)->create();
$this->getJson(route('admin.users.list'))
->assertJson(['data' => [[/* admin is here */], $user->toArray()]]);
}
public function testAccessControl()
{
// an administrator operating on other administrator should be forbidden
$otherAdmin = factory(User::class)->states('admin')->create();
$this->putJson(route('admin.users.email', ['user' => $otherAdmin->uid]))
->assertJson([
'code' => 1,
'message' => trans('admin.users.operations.no-permission'),
])
->assertForbidden();
}
public function testEmail()
{
$user = factory(User::class)->create();
// without `email` field
$this->putJson(route('admin.users.email', ['user' => $user]))
->assertJsonValidationErrors(['email']);
// with an invalid email address
$this->putJson(
route('admin.users.email', ['user' => $user]),
['email' => 'invalid']
)->assertJsonValidationErrors(['email']);
// use an existed email address
$other = factory(User::class)->create();
$this->putJson(
route('admin.users.email', ['user' => $user]),
['email' => $other->email]
)->assertJsonValidationErrors(['email']);
// update successfully
Event::fake();
$this->putJson(
route('admin.users.email', ['user' => $user]),
['email' => 'a@b.c']
)->assertJson([
'code' => 0,
'message' => trans('admin.users.operations.email.success'),
]);
$this->assertDatabaseHas('users', [
'uid' => $user->uid,
'email' => 'a@b.c',
]);
Event::assertDispatched('user.email.updating', function ($eventName, $payload) use ($user) {
$this->assertTrue($user->is($payload[0]));
$this->assertEquals('a@b.c', $payload[1]);
return true;
});
Event::assertDispatched('user.email.updated', function ($eventName, $payload) use ($user) {
$this->assertTrue($user->fresh()->is($payload[0]));
$this->assertEquals($user->email, $payload[1]->email);
return true;
});
}
public function testVerification()
{
Event::fake();
$user = factory(User::class)->create();
$this->putJson(
route('admin.users.verification', ['user' => $user])
)->assertJson([
'code' => 0,
'message' => trans('admin.users.operations.verification.success'),
]);
$this->assertDatabaseHas('users', [
'uid' => $user->uid,
'verified' => false,
]);
Event::assertDispatched('user.verification.updating', function ($eventName, $payload) use ($user) {
$this->assertInstanceOf(User::class, $payload[0]);
$this->assertEquals($user->uid, $payload[0]->uid);
return true;
});
Event::assertDispatched('user.verification.updated', function ($eventName, $payload) {
$this->assertFalse($payload[0]->verified);
return true;
});
}
public function testNickname()
{
$user = factory(User::class)->create();
// without `nickname` field
$this->putJson(
route('admin.users.nickname', ['user' => $user])
)->assertJsonValidationErrors(['nickname']);
// update successfully
Event::fake();
$this->putJson(
route('admin.users.nickname', ['user' => $user]),
['nickname' => 'nickname']
)->assertJson([
'code' => 0,
'message' => trans('admin.users.operations.nickname.success', ['new' => 'nickname']),
]);
$this->assertDatabaseHas('users', [
'uid' => $user->uid,
'nickname' => 'nickname',
]);
Event::assertDispatched('user.nickname.updating', function ($eventName, $payload) use ($user) {
$this->assertTrue($user->is($payload[0]));
$this->assertEquals('nickname', $payload[1]);
return true;
});
Event::assertDispatched('user.nickname.updated', function ($eventName, $payload) use ($user) {
$this->assertTrue($user->fresh()->is($payload[0]));
$this->assertEquals($user->nickname, $payload[1]->nickname);
return true;
});
}
public function testPassword()
{
$user = factory(User::class)->create();
// without `password` field
$this->putJson(
route('admin.users.password', ['user' => $user])
)->assertJsonValidationErrors(['password']);
// too short password
$this->putJson(
route('admin.users.password', ['user' => $user]),
['password' => '1']
)->assertJsonValidationErrors(['password']);
// too long password
$this->putJson(
route('admin.users.password', ['user' => $user]),
['password' => Str::random(17)]
)->assertJsonValidationErrors(['password']);
// update successfully
Event::fake();
$this->putJson(
route('admin.users.password', ['user' => $user]),
['password' => '12345678']
)->assertJson([
'code' => 0,
'message' => trans('admin.users.operations.password.success'),
]);
$this->assertTrue($user->fresh()->verifyPassword('12345678'));
Event::assertDispatched('user.password.updating', function ($eventName, $payload) use ($user) {
$this->assertTrue($user->is($payload[0]));
$this->assertEquals('12345678', $payload[1]);
return true;
});
Event::assertDispatched('user.password.updated', function ($eventName, $payload) use ($user) {
$this->assertTrue($user->fresh()->is($payload[0]));
return true;
});
}
public function testScore()
{
$user = factory(User::class)->create();
// without `score` field
$this->putJson(
route('admin.users.score', ['user' => $user])
)->assertJsonValidationErrors(['score']);
// with an non-integer value
$this->putJson(
route('admin.users.score', ['user' => $user]),
['score' => 'string']
)->assertJsonValidationErrors(['score']);
// update successfully
Event::fake();
$this->putJson(
route('admin.users.score', ['user' => $user]),
['score' => 123]
)->assertJson([
'code' => 0,
'message' => trans('admin.users.operations.score.success'),
]);
$this->assertDatabaseHas('users', [
'uid' => $user->uid,
'score' => 123,
]);
Event::assertDispatched('user.score.updating', function ($eventName, $payload) use ($user) {
$this->assertTrue($user->is($payload[0]));
$this->assertEquals(123, $payload[1]);
return true;
});
Event::assertDispatched('user.score.updated', function ($eventName, $payload) use ($user) {
$this->assertTrue($user->fresh()->is($payload[0]));
$this->assertEquals($user->score, $payload[1]->score);
return true;
});
}
public function testPermission()
{
$user = factory(User::class)->create();
// without `permission` field
$this->putJson(route('admin.users.permission', ['user' => $user]))
->assertJsonValidationErrors(['permission']);
// invalid permission value
$this->putJson(
route('admin.users.permission', ['user' => $user]),
['permission' => -2]
)->assertJsonValidationErrors(['permission']);
// non-super administrator can't set normal user as administrator
$this->putJson(
route('admin.users.permission', ['user' => $user]),
['permission' => User::ADMIN]
)->assertJson([
'code' => 1,
'message' => trans('admin.users.operations.no-permission'),
])->assertForbidden();
// administrator can't modify his/her permission
$this->putJson(
route('admin.users.permission', ['user' => auth()->user()]),
['permission' => User::NORMAL]
)->assertJson([
'code' => 1,
'message' => trans('admin.users.operations.no-permission'),
])->assertForbidden();
// update successfully
Event::fake();
$this->putJson(
route('admin.users.permission', ['user' => $user]),
['permission' => User::BANNED]
)->assertJson([
'code' => 0,
'message' => trans('admin.users.operations.permission'),
]);
$this->assertEquals(User::BANNED, $user->fresh()->permission);
Event::assertDispatched('user.permission.updating', function ($eventName, $payload) use ($user) {
$this->assertTrue($user->is($payload[0]));
$this->assertEquals(User::BANNED, $payload[1]);
return true;
});
Event::assertDispatched('user.permission.updated', function ($eventName, $payload) use ($user) {
$this->assertTrue($user->fresh()->is($payload[0]));
$this->assertEquals($user->permission, $payload[1]->permission);
return true;
});
}
public function testDelete()
{
Event::fake();
$user = factory(User::class)->create();
$this->deleteJson(route('admin.users.delete', ['user' => $user]))
->assertJson([
'code' => 0,
'message' => trans('admin.users.operations.delete.success'),
]);
$this->assertNull(User::find($user->uid));
Event::assertDispatched('user.deleting', function ($eventName, $payload) use ($user) {
$this->assertTrue($user->is($payload[0]));
return true;
});
Event::assertDispatched('user.deleted', function ($eventName, $payload) use ($user) {
$this->assertTrue($user->is($payload[0]));
return true;
});
}
}