From 3e1a10a461ea0db3c799457fc1aece4247758f7f Mon Sep 17 00:00:00 2001 From: Pig Fang Date: Wed, 13 May 2020 18:12:01 +0800 Subject: [PATCH] rewrite users management page with React --- app/Http/Controllers/AdminController.php | 117 --- .../Controllers/UsersManagementController.php | 170 ++++ app/Models/User.php | 12 + resources/assets/src/components/Modal.vue | 198 ----- resources/assets/src/scripts/route.tsx | 2 +- resources/assets/src/scripts/types.ts | 9 +- resources/assets/src/views/admin/Users.vue | 362 -------- .../admin/UsersManagement/Card.module.scss | 27 + .../src/views/admin/UsersManagement/Card.tsx | 157 ++++ .../src/views/admin/UsersManagement/Row.tsx | 114 +++ .../src/views/admin/UsersManagement/index.tsx | 364 ++++++++ .../src/views/admin/UsersManagement/utils.ts | 27 + .../assets/tests/components/Modal.test.ts | 216 ----- .../assets/tests/views/admin/Users.test.ts | 467 ---------- .../views/admin/UsersManagement.test.tsx | 798 ++++++++++++++++++ resources/misc/changelogs/en/5.0.0.md | 1 + resources/misc/changelogs/zh_CN/5.0.0.md | 1 + resources/views/admin/users.twig | 7 + routes/api.php | 13 + routes/web.php | 20 +- .../ControllersTest/AdminControllerTest.php | 212 ----- .../UsersManagementControllerTest.php | 317 +++++++ 22 files changed, 2031 insertions(+), 1580 deletions(-) create mode 100644 app/Http/Controllers/UsersManagementController.php delete mode 100644 resources/assets/src/components/Modal.vue delete mode 100644 resources/assets/src/views/admin/Users.vue create mode 100644 resources/assets/src/views/admin/UsersManagement/Card.module.scss create mode 100644 resources/assets/src/views/admin/UsersManagement/Card.tsx create mode 100644 resources/assets/src/views/admin/UsersManagement/Row.tsx create mode 100644 resources/assets/src/views/admin/UsersManagement/index.tsx create mode 100644 resources/assets/src/views/admin/UsersManagement/utils.ts delete mode 100644 resources/assets/tests/components/Modal.test.ts delete mode 100644 resources/assets/tests/views/admin/Users.test.ts create mode 100644 resources/assets/tests/views/admin/UsersManagement.test.tsx create mode 100644 tests/HttpTest/ControllersTest/UsersManagementControllerTest.php diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index dda40b6c..c7e01423 100644 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -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); - } - } } diff --git a/app/Http/Controllers/UsersManagementController.php b/app/Http/Controllers/UsersManagementController.php new file mode 100644 index 00000000..8101a8c4 --- /dev/null +++ b/app/Http/Controllers/UsersManagementController.php @@ -0,0 +1,170 @@ +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); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index b759bd03..15047f9a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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; diff --git a/resources/assets/src/components/Modal.vue b/resources/assets/src/components/Modal.vue deleted file mode 100644 index e6add3a0..00000000 --- a/resources/assets/src/components/Modal.vue +++ /dev/null @@ -1,198 +0,0 @@ - - - diff --git a/resources/assets/src/scripts/route.tsx b/resources/assets/src/scripts/route.tsx index 310878f5..02798a6b 100644 --- a/resources/assets/src/scripts/route.tsx +++ b/resources/assets/src/scripts/route.tsx @@ -53,7 +53,7 @@ export default [ }, { path: 'admin/users', - component: () => import('../views/admin/Users.vue'), + react: () => import('../views/admin/UsersManagement'), el: '.content > .container-fluid', }, { diff --git a/resources/assets/src/scripts/types.ts b/resources/assets/src/scripts/types.ts index b1b2cc09..6aedcdfa 100644 --- a/resources/assets/src/scripts/types.ts +++ b/resources/assets/src/scripts/types.ts @@ -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 diff --git a/resources/assets/src/views/admin/Users.vue b/resources/assets/src/views/admin/Users.vue deleted file mode 100644 index 780b3851..00000000 --- a/resources/assets/src/views/admin/Users.vue +++ /dev/null @@ -1,362 +0,0 @@ - - - - - diff --git a/resources/assets/src/views/admin/UsersManagement/Card.module.scss b/resources/assets/src/views/admin/UsersManagement/Card.module.scss new file mode 100644 index 00000000..28000038 --- /dev/null +++ b/resources/assets/src/views/admin/UsersManagement/Card.module.scss @@ -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; + } +} diff --git a/resources/assets/src/views/admin/UsersManagement/Card.tsx b/resources/assets/src/views/admin/UsersManagement/Card.tsx new file mode 100644 index 00000000..c7549283 --- /dev/null +++ b/resources/assets/src/views/admin/UsersManagement/Card.tsx @@ -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) => { + const { user, currentUser } = props + + const canModify = canModifyUser(user, currentUser) + + return ( +
+
+ +
+
+
+
+ {user.nickname} +
+ +
+
+
UID: {user.uid}
+
+ {t('general.user.email')} + {': '} + {user.email} +
+
+
+ {t('general.user.score')} + {user.score} +
+
+ {t('admin.permission')} + + {humanizePermission(user.permission)} + +
+
+ {t('admin.verification')} + + {verificationStatusText(user.verified)} + +
+
+
+ + {t('general.user.register-at')} + {': '} + {user.register_at} + +
+
+
+
+ ) +} + +export default Card diff --git a/resources/assets/src/views/admin/UsersManagement/Row.tsx b/resources/assets/src/views/admin/UsersManagement/Row.tsx new file mode 100644 index 00000000..d2ef64e1 --- /dev/null +++ b/resources/assets/src/views/admin/UsersManagement/Row.tsx @@ -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) => { + const { user, currentUser } = props + + const canModify = canModifyUser(user, currentUser) + + return ( + + {user.uid} + + {user.email} + {canModify && ( + + + + )} + + + {user.nickname} + {canModify && ( + + + + )} + + + {user.score} + {canModify && ( + + + + )} + + + {humanizePermission(user.permission)} + {canModifyPermission(user, currentUser) && ( + + + + )} + + + {verificationStatusText(user.verified)} + {canModify && ( + + {user.verified ? ( + + ) : ( + + )} + + )} + + {user.register_at} + + + + + + ) +} + +export default Row diff --git a/resources/assets/src/views/admin/UsersManagement/index.tsx b/resources/assets/src/views/admin/UsersManagement/index.tsx new file mode 100644 index 00000000..b93343e8 --- /dev/null +++ b/resources/assets/src/views/admin/UsersManagement/index.tsx @@ -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([]) + 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('currentUser', { + uid: 0, + permission: UserPermission.Admin, + } as User) + + useEffect(() => { + if (isLargeScreen) { + setIsTableMode(true) + } + }, [isLargeScreen]) + + const getUsers = async () => { + setIsLoading(true) + const { data, last_page }: Paginator = await fetch.get( + '/admin/users/list', + { + q: query, + page, + }, + ) + setUsers(() => data) + setTotalPages(last_page) + setIsLoading(false) + } + + useEffect(() => { + getUsers() + }, [page]) + + const handleModeChange = (event: React.ChangeEvent) => { + setIsTableMode(event.target.value === 'table') + } + + const handleQueryChange = (event: React.ChangeEvent) => { + 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( + `/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( + `/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( + `/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( + `/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( + `/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( + `/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 ( +
+
+
+ +
+ +
+
+
+ + +
+
+ {isLoading ? ( +
+ +
+ ) : users.length === 0 ? ( +
{t('general.noResult')}
+ ) : isTableMode ? ( +
+ + + + + + + + + + + + + + + {users.map((user, i) => ( + 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)} + /> + ))} + +
UID{t('general.user.email')}{t('general.user.nickname')}{t('general.user.score')}{t('admin.permission')}{t('admin.verification')}{t('general.user.register-at')}{t('admin.operationsTitle')}
+
+ ) : ( +
+ {users.map((user, i) => ( + 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)} + /> + ))} +
+ )} +
+
+ +
+
+
+ ) +} + +export default hot(UsersManagement) diff --git a/resources/assets/src/views/admin/UsersManagement/utils.ts b/resources/assets/src/views/admin/UsersManagement/utils.ts new file mode 100644 index 00000000..3ef0b83f --- /dev/null +++ b/resources/assets/src/views/admin/UsersManagement/utils.ts @@ -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 +} diff --git a/resources/assets/tests/components/Modal.test.ts b/resources/assets/tests/components/Modal.test.ts deleted file mode 100644 index 4a80291b..00000000 --- a/resources/assets/tests/components/Modal.test.ts +++ /dev/null @@ -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: '
kumiko
', - }, - }) - 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: '
reina
', - }, - }) - expect(wrapper.find('.modal-body > .trumpet').text()).toBe('reina') -}) - -test('footer slot', () => { - const wrapper = mount(Modal, { - slots: { - footer: '
sapphire
', - }, - }) - 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) -}) diff --git a/resources/assets/tests/views/admin/Users.test.ts b/resources/assets/tests/views/admin/Users.test.ts deleted file mode 100644 index 6287170e..00000000 --- a/resources/assets/tests/views/admin/Users.test.ts +++ /dev/null @@ -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') -}) diff --git a/resources/assets/tests/views/admin/UsersManagement.test.tsx b/resources/assets/tests/views/admin/UsersManagement.test.tsx new file mode 100644 index 00000000..2f349389 --- /dev/null +++ b/resources/assets/tests/views/admin/UsersManagement.test.tsx @@ -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 = Object.freeze({ + 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() + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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() + + 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( + , + ) + + 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( + , + ) + + 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() + + 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( + , + ) + + 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( + , + ) + + 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( + , + ) + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + }) +}) diff --git a/resources/misc/changelogs/en/5.0.0.md b/resources/misc/changelogs/en/5.0.0.md index 25014211..45b87f3a 100644 --- a/resources/misc/changelogs/en/5.0.0.md +++ b/resources/misc/changelogs/en/5.0.0.md @@ -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 diff --git a/resources/misc/changelogs/zh_CN/5.0.0.md b/resources/misc/changelogs/zh_CN/5.0.0.md index e8ddcf59..35e422ba 100644 --- a/resources/misc/changelogs/zh_CN/5.0.0.md +++ b/resources/misc/changelogs/zh_CN/5.0.0.md @@ -72,6 +72,7 @@ - 修复未登录的用户在浏览皮肤库时出现「我的上传」按钮问题 - 修复可能的「Invalid Signature」问题 - 修复在管理面板中修改角色名时不检测角色名是否重复的问题 +- 修复普通管理员可设置其他用户为管理员的问题 ## 移除 diff --git a/resources/views/admin/users.twig b/resources/views/admin/users.twig index 421e4d0a..138dba3f 100644 --- a/resources/views/admin/users.twig +++ b/resources/views/admin/users.twig @@ -1,3 +1,10 @@ {% extends 'admin.base' %} {% block title %}{{ trans('general.user-manage') }}{% endblock %} + +{% block before_foot %} + {% set extra = {'currentUser': auth_user()} %} + +{% endblock %} diff --git a/routes/api.php b/routes/api.php index 87944514..51635150 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); diff --git a/routes/web.php b/routes/web.php index dfb888cc..d30e0ff9 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/HttpTest/ControllersTest/AdminControllerTest.php b/tests/HttpTest/ControllersTest/AdminControllerTest.php index 306da190..38d6fcdc 100644 --- a/tests/HttpTest/ControllersTest/AdminControllerTest.php +++ b/tests/HttpTest/ControllersTest/AdminControllerTest.php @@ -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)); - } } diff --git a/tests/HttpTest/ControllersTest/UsersManagementControllerTest.php b/tests/HttpTest/ControllersTest/UsersManagementControllerTest.php new file mode 100644 index 00000000..5f6cd98f --- /dev/null +++ b/tests/HttpTest/ControllersTest/UsersManagementControllerTest.php @@ -0,0 +1,317 @@ +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; + }); + } +}