diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index 71976160..8c248414 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -193,10 +193,8 @@ class UserController extends Controller $user = Auth::user(); return view('user.profile') - ->with('extra', [ - 'unverified' => option('require_verification') && ! $user->verified, - 'admin' => $user->isAdmin(), - ]); + ->with('user', $user) + ->with('site_name', option_localized('site_name')); } public function handleProfile(Request $request, Filter $filter, Dispatcher $dispatcher) diff --git a/resources/assets/src/scripts/net.ts b/resources/assets/src/scripts/net.ts index b962ad09..36fbfaeb 100644 --- a/resources/assets/src/scripts/net.ts +++ b/resources/assets/src/scripts/net.ts @@ -4,6 +4,11 @@ import { queryStringify } from './utils' import { showModal } from './notify' import { trans } from './i18n' +export interface ResponseBody { + code: number + message: string +} + class HTTPError extends Error { response: Response diff --git a/resources/assets/src/scripts/route.ts b/resources/assets/src/scripts/route.ts index 636550db..bb819037 100644 --- a/resources/assets/src/scripts/route.ts +++ b/resources/assets/src/scripts/route.ts @@ -33,8 +33,9 @@ export default [ }, { path: 'user/profile', - component: () => import('../views/user/Profile.vue'), - el: '.content > .container-fluid', + module: [ + () => import('../views/user/profile/index'), + ], }, { path: 'user/oauth/manage', diff --git a/resources/assets/src/views/user/Profile.vue b/resources/assets/src/views/user/Profile.vue deleted file mode 100644 index db5c958c..00000000 --- a/resources/assets/src/views/user/Profile.vue +++ /dev/null @@ -1,300 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {{ $t('user.profile.delete.button') }} - - - - - - {{ $t('user.profile.delete.button') }} - - - - - - - - - - - - - - - - - - - - - - - diff --git a/resources/assets/src/views/user/profile/deleteAccount.ts b/resources/assets/src/views/user/profile/deleteAccount.ts new file mode 100644 index 00000000..919e807a --- /dev/null +++ b/resources/assets/src/views/user/profile/deleteAccount.ts @@ -0,0 +1,19 @@ +import { post, ResponseBody } from '../../../scripts/net' +import { showModal } from '../../../scripts/notify' + +export default async function handler(event: Event) { + event.preventDefault() + + const form = event.target as HTMLFormElement + const password: string = form.password.value + + const { code, message }: ResponseBody = await post( + '/user/profile?action=delete', + { password }, + ) + + await showModal({ mode: 'alert', text: message }) + if (code === 0) { + window.location.href = blessing.base_url + } +} diff --git a/resources/assets/src/views/user/profile/email.ts b/resources/assets/src/views/user/profile/email.ts new file mode 100644 index 00000000..86a45695 --- /dev/null +++ b/resources/assets/src/views/user/profile/email.ts @@ -0,0 +1,19 @@ +import { post, ResponseBody } from '../../../scripts/net' +import { showModal } from '../../../scripts/notify' + +export default async function handler(event: Event) { + event.preventDefault() + + const form = event.target as HTMLFormElement + const email: string = form.email.value + const password: string = form.password.value + + const { code, message }: ResponseBody = await post( + '/user/profile?action=email', + { new_email: email, password }, + ) + await showModal({ mode: 'alert', text: message }) + if (code === 0) { + window.location.href = `${blessing.base_url}/auth/login` + } +} diff --git a/resources/assets/src/views/user/profile/index.ts b/resources/assets/src/views/user/profile/index.ts new file mode 100644 index 00000000..c22a64bd --- /dev/null +++ b/resources/assets/src/views/user/profile/index.ts @@ -0,0 +1,31 @@ +import resetAvatar from './resetAvatar' +import passwordFormHandler from './password' +import nicknameFormHandler from './nickname' +import emailFormHandler from './email' +import deleteAccountFormHandler from './deleteAccount' + +const btnResetAvatar = document.querySelector('#reset-avatar') +if (btnResetAvatar) { + btnResetAvatar.addEventListener('click', resetAvatar) +} + +const passwordForm = document.querySelector('#change-password') +if (passwordForm) { + passwordForm.addEventListener('submit', passwordFormHandler) +} + +const nicknameForm = document.querySelector('#change-nickname') +if (nicknameForm) { + nicknameForm.addEventListener('submit', nicknameFormHandler) +} + +const emailForm = document.querySelector('#change-email') +if (emailForm) { + emailForm.addEventListener('submit', emailFormHandler) +} + +const deleteAccountForm = document + .querySelector('#modal-delete-account') +if (deleteAccountForm) { + deleteAccountForm.addEventListener('submit', deleteAccountFormHandler) +} diff --git a/resources/assets/src/views/user/profile/nickname.ts b/resources/assets/src/views/user/profile/nickname.ts new file mode 100644 index 00000000..473ab25a --- /dev/null +++ b/resources/assets/src/views/user/profile/nickname.ts @@ -0,0 +1,21 @@ +import { post, ResponseBody } from '../../../scripts/net' +import { showModal } from '../../../scripts/notify' + +export default async function handler(event: Event) { + event.preventDefault() + + const form = event.target as HTMLFormElement + const nickname: string = form.nickname.value + + const { code, message }: ResponseBody = await post( + '/user/profile?action=nickname', { new_nickname: nickname }, + ) + showModal({ mode: 'alert', text: message }) + if (code === 0) { + document + .querySelectorAll('[data-mark="nickname"]') + .forEach(el => { + el.textContent = nickname + }) + } +} diff --git a/resources/assets/src/views/user/profile/password.ts b/resources/assets/src/views/user/profile/password.ts new file mode 100644 index 00000000..19854e44 --- /dev/null +++ b/resources/assets/src/views/user/profile/password.ts @@ -0,0 +1,27 @@ +import { post, ResponseBody } from '../../../scripts/net' +import { trans } from '../../../scripts/i18n' +import { showModal, toast } from '../../../scripts/notify' + +export default async function handler(event: Event) { + event.preventDefault() + + const form = event.target as HTMLFormElement + const oldPassword = form.oldPassword.value + const newPassword = form.newPassword.value + const confirmPassword = form.confirm.value + + if (newPassword !== confirmPassword) { + toast.error(trans('auth.invalidConfirmPwd')) + ;(form.confirm as HTMLInputElement).focus() + return + } + + const { code, message }: ResponseBody = await post( + '/user/profile?action=password', + { current_password: oldPassword, new_password: newPassword }, + ) + await showModal({ mode: 'alert', text: message }) + if (code === 0) { + window.location.href = `${blessing.base_url}/auth/login` + } +} diff --git a/resources/assets/src/views/user/profile/resetAvatar.ts b/resources/assets/src/views/user/profile/resetAvatar.ts new file mode 100644 index 00000000..14cc80df --- /dev/null +++ b/resources/assets/src/views/user/profile/resetAvatar.ts @@ -0,0 +1,21 @@ +import { showModal, toast } from '../../../scripts/notify' +import { trans } from '../../../scripts/i18n' +import { post, ResponseBody } from '../../../scripts/net' + +export default async function resetAvatar() { + try { + await showModal({ text: trans('user.resetAvatarConfirm') }) + } catch { + return + } + + const { message }: ResponseBody = await post( + '/user/profile/avatar', + { tid: 0 }, + ) + toast.success(message) + document.querySelectorAll('[alt="User Image"]') + .forEach(el => { + el.src += `?${new Date().getTime()}` + }) +} diff --git a/resources/assets/tests/views/user/Profile.test.ts b/resources/assets/tests/views/user/Profile.test.ts deleted file mode 100644 index bd19b6de..00000000 --- a/resources/assets/tests/views/user/Profile.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import Vue from 'vue' -import { mount } from '@vue/test-utils' -import { flushPromises } from '../../utils' -import { showModal, toast } from '@/scripts/notify' -import Profile from '@/views/user/Profile.vue' - -jest.mock('@/scripts/notify') - -window.blessing.extra = { unverified: false } - -test('computed values', () => { - window.blessing.extra = { admin: true } - const wrapper = mount(Profile) - expect(wrapper.vm.siteName).toBe('Blessing Skin') - expect(wrapper.vm.isAdmin).toBeTrue() - window.blessing.extra = { admin: false } - expect(mount(Profile).vm.isAdmin).toBeFalse() -}) - -test('convert linebreak', () => { - const wrapper = mount(Profile) - expect(wrapper.vm.nl2br('a\nb\nc')).toBe('abc') -}) - -test('reset avatar', async () => { - showModal - .mockRejectedValueOnce(null) - .mockResolvedValue({ value: '' }) - Vue.prototype.$http.post.mockResolvedValue({ message: 'ok' }) - const wrapper = mount(Profile) - const button = wrapper.find('[data-test=resetAvatar]') - document.body.innerHTML += '' - - button.trigger('click') - expect(Vue.prototype.$http.post).not.toBeCalled() - - button.trigger('click') - await flushPromises() - expect(Vue.prototype.$http.post).toBeCalledWith( - '/user/profile/avatar', - { tid: 0 }, - ) - await flushPromises() - expect(toast.success).toBeCalledWith('ok') - expect(document.querySelector('img')!.src).toMatch(/\d+$/) -}) - -test('change password', async () => { - Vue.prototype.$http.post - .mockResolvedValueOnce({ code: 1, message: 'w' }) - .mockResolvedValueOnce({ code: 0, message: 'o' }) - const wrapper = mount(Profile) - const form = wrapper.find('[data-test=changePassword]') - - wrapper.setData({ oldPassword: '1' }) - wrapper.setData({ newPassword: '1' }) - wrapper.setData({ confirmPassword: '2' }) - form.trigger('submit') - expect(toast.error).toBeCalledWith('auth.invalidConfirmPwd') - expect(Vue.prototype.$http.post).not.toBeCalled() - - wrapper.setData({ confirmPassword: '1' }) - form.trigger('submit') - await flushPromises() - expect(Vue.prototype.$http.post).toBeCalledWith( - '/user/profile?action=password', - { current_password: '1', new_password: '1' }, - ) - expect(showModal).toBeCalledWith({ mode: 'alert', text: 'w' }) - - form.trigger('submit') - await flushPromises() - expect(showModal).toBeCalledWith({ mode: 'alert', text: 'o' }) -}) - -test('change nickname', async () => { - Vue.prototype.$http.post - .mockResolvedValueOnce({ code: 1, message: 'w' }) - .mockResolvedValue({ code: 0, message: 'o' }) - const wrapper = mount(Profile) - const form = wrapper.find('[data-test=changeNickName]') - document.body.innerHTML += '' - - wrapper.setData({ nickname: 'nickname' }) - form.trigger('submit') - await flushPromises() - expect(Vue.prototype.$http.post).toBeCalledWith( - '/user/profile?action=nickname', - { new_nickname: 'nickname' }, - ) - await flushPromises() - expect(showModal).toBeCalledWith({ mode: 'alert', text: 'w' }) - - form.trigger('submit') - await flushPromises() - expect(toast.success).toBeCalledWith('o') - expect(document.querySelector('span')!.textContent).toBe('nickname') -}) - -test('change email', async () => { - Vue.prototype.$http.post - .mockResolvedValueOnce({ code: 1, message: 'w' }) - .mockResolvedValue({ code: 0, message: 'o' }) - const wrapper = mount(Profile) - const form = wrapper.find('[data-test=changeEmail]') - - wrapper.setData({ email: 'a@b.c', currentPassword: 'abc' }) - form.trigger('submit') - await flushPromises() - expect(Vue.prototype.$http.post).toBeCalledWith( - '/user/profile?action=email', - { new_email: 'a@b.c', password: 'abc' }, - ) - await flushPromises() - expect(showModal).toBeCalledWith({ mode: 'alert', text: 'w' }) - - form.trigger('submit') - await flushPromises() - expect(showModal).toBeCalledWith({ mode: 'alert', text: 'o' }) -}) - -test('delete account', async () => { - window.blessing.extra = { admin: true } - Vue.prototype.$http.post - .mockResolvedValueOnce({ code: 1, message: 'w' }) - .mockResolvedValue({ code: 0, message: 'o' }) - const wrapper = mount(Profile) - const form = wrapper.find('[data-test=deleteAccount]') - - wrapper.setData({ deleteConfirm: 'abc' }) - form.trigger('submit') - expect(Vue.prototype.$http.post).toBeCalledWith( - '/user/profile?action=delete', - { password: 'abc' }, - ) - await flushPromises() - expect(showModal).toBeCalledWith({ mode: 'alert', text: 'w' }) - - form.trigger('submit') - await flushPromises() - expect(showModal).toBeCalledWith({ mode: 'alert', text: 'w' }) -}) diff --git a/resources/assets/tests/views/user/profile/deleteAccount.test.ts b/resources/assets/tests/views/user/profile/deleteAccount.test.ts new file mode 100644 index 00000000..943de68c --- /dev/null +++ b/resources/assets/tests/views/user/profile/deleteAccount.test.ts @@ -0,0 +1,35 @@ +import { flushPromises } from '../../../utils' +import { showModal } from '@/scripts/notify' +import { post } from '@/scripts/net' +import handler from '@/views/user/profile/deleteAccount' + +jest.mock('@/scripts/notify') +jest.mock('@/scripts/net') + +test('delete account', async () => { + post + .mockResolvedValueOnce({ code: 1, message: 'w' }) + .mockResolvedValue({ code: 0, message: 'o' }) + + const form = document.createElement('form') + form.addEventListener('submit', handler) + + const password = document.createElement('input') + password.name = 'password' + password.value = 'abc' + form.appendChild(password) + form.password = password + + const event = new Event('submit') + form.dispatchEvent(event) + await flushPromises() + expect(post).toBeCalledWith( + '/user/profile?action=delete', + { password: 'abc' }, + ) + expect(showModal).toBeCalledWith({ mode: 'alert', text: 'w' }) + + form.dispatchEvent(event) + await flushPromises() + expect(showModal).toBeCalledWith({ mode: 'alert', text: 'o' }) +}) diff --git a/resources/assets/tests/views/user/profile/email.test.ts b/resources/assets/tests/views/user/profile/email.test.ts new file mode 100644 index 00000000..78e14d1f --- /dev/null +++ b/resources/assets/tests/views/user/profile/email.test.ts @@ -0,0 +1,41 @@ +import { flushPromises } from '../../../utils' +import { showModal } from '@/scripts/notify' +import { post } from '@/scripts/net' +import handler from '@/views/user/profile/email' + +jest.mock('@/scripts/notify') +jest.mock('@/scripts/net') + +test('change email', async () => { + post + .mockResolvedValueOnce({ code: 1, message: 'w' }) + .mockResolvedValue({ code: 0, message: 'o' }) + + const form = document.createElement('form') + form.addEventListener('submit', handler) + + const email = document.createElement('input') + email.name = 'email' + email.value = 'a@b.c' + form.appendChild(email) + form.email = email + + const password = document.createElement('input') + password.name = 'password' + password.value = 'abc' + form.appendChild(password) + form.password = password + + const event = new Event('submit') + form.dispatchEvent(event) + await flushPromises() + expect(post).toBeCalledWith( + '/user/profile?action=email', + { new_email: 'a@b.c', password: 'abc' }, + ) + expect(showModal).toBeCalledWith({ mode: 'alert', text: 'w' }) + + form.dispatchEvent(event) + await flushPromises() + expect(showModal).toBeCalledWith({ mode: 'alert', text: 'o' }) +}) diff --git a/resources/assets/tests/views/user/profile/nickname.test.ts b/resources/assets/tests/views/user/profile/nickname.test.ts new file mode 100644 index 00000000..1f16bc21 --- /dev/null +++ b/resources/assets/tests/views/user/profile/nickname.test.ts @@ -0,0 +1,37 @@ +import { flushPromises } from '../../../utils' +import { showModal } from '@/scripts/notify' +import { post } from '@/scripts/net' +import handler from '@/views/user/profile/nickname' + +jest.mock('@/scripts/notify') +jest.mock('@/scripts/net') + +test('change nickname', async () => { + document.body.innerHTML = '' + post + .mockResolvedValueOnce({ code: 1, message: 'w' }) + .mockResolvedValue({ code: 0, message: 'o' }) + + const form = document.createElement('form') + form.addEventListener('submit', handler) + + const nickname = document.createElement('input') + nickname.name = 'nickname' + nickname.value = 'nickname' + form.appendChild(nickname) + form.nickname = nickname + + const event = new Event('submit') + form.dispatchEvent(event) + await flushPromises() + expect(post).toBeCalledWith( + '/user/profile?action=nickname', + { new_nickname: 'nickname' }, + ) + expect(showModal).toBeCalledWith({ mode: 'alert', text: 'w' }) + + form.dispatchEvent(event) + await flushPromises() + expect(showModal).toBeCalledWith({ mode: 'alert', text: 'o' }) + expect(document.querySelector('span')!.textContent).toBe('nickname') +}) diff --git a/resources/assets/tests/views/user/profile/password.test.ts b/resources/assets/tests/views/user/profile/password.test.ts new file mode 100644 index 00000000..5aa060e4 --- /dev/null +++ b/resources/assets/tests/views/user/profile/password.test.ts @@ -0,0 +1,53 @@ +import { flushPromises } from '../../../utils' +import { showModal, toast } from '@/scripts/notify' +import { post } from '@/scripts/net' +import handler from '@/views/user/profile/password' + +jest.mock('@/scripts/notify') +jest.mock('@/scripts/net') + +test('change password', async () => { + post + .mockResolvedValueOnce({ code: 1, message: 'w' }) + .mockResolvedValue({ code: 0, message: 'o' }) + + const form = document.createElement('form') + form.addEventListener('submit', handler) + + const oldPassword = document.createElement('input') + oldPassword.name = 'oldPassword' + oldPassword.value = '1' + form.appendChild(oldPassword) + form.oldPassword = oldPassword + + const newPassword = document.createElement('input') + newPassword.name = 'newPassword' + newPassword.value = '1' + form.appendChild(newPassword) + form.newPassword = newPassword + + const confirm = document.createElement('input') + confirm.name = 'confirm' + confirm.value = '2' + form.appendChild(confirm) + form.confirm = confirm + + const event = new Event('submit') + form.dispatchEvent(event) + await flushPromises() + expect(post).not.toBeCalled() + expect(toast.error).toBeCalledWith('auth.invalidConfirmPwd') + + confirm.value = '1' + form.dispatchEvent(event) + await flushPromises() + expect(post).toBeCalledWith( + '/user/profile?action=password', + { current_password: '1', new_password: '1' }, + ) + expect(showModal).toBeCalledWith({ mode: 'alert', text: 'w' }) + + form.dispatchEvent(event) + await flushPromises() + expect(showModal).toBeCalledWith({ mode: 'alert', text: 'o' }) +}) diff --git a/resources/assets/tests/views/user/profile/resetAvatar.test.ts b/resources/assets/tests/views/user/profile/resetAvatar.test.ts new file mode 100644 index 00000000..3eec65a9 --- /dev/null +++ b/resources/assets/tests/views/user/profile/resetAvatar.test.ts @@ -0,0 +1,22 @@ +import { showModal, toast } from '@/scripts/notify' +import { post } from '@/scripts/net' +import resetAvatar from '@/views/user/profile/resetAvatar' + +jest.mock('@/scripts/notify') +jest.mock('@/scripts/net') + +test('reset avatar', async () => { + showModal + .mockRejectedValueOnce(null) + .mockResolvedValue({ value: '' }) + post.mockResolvedValue({ message: 'ok' }) + document.body.innerHTML = '' + + await resetAvatar() + expect(post).not.toBeCalled() + + await resetAvatar() + expect(post).toBeCalledWith('/user/profile/avatar', { tid: 0 }) + expect(toast.success).toBeCalledWith('ok') + expect(document.querySelector('img')!.src).toMatch(/\d+$/) +}) diff --git a/resources/lang/en/user.yml b/resources/lang/en/user.yml index 752f876e..2c0a46ff 100644 --- a/resources/lang/en/user.yml +++ b/resources/lang/en/user.yml @@ -119,6 +119,7 @@ profile: notice: Click the gear icon "" of any skin in your closet, then click "Set as avatar". We will cut the head segment of that skin for you. If there is no icon like this, please try to unable your ADs blocking extension. wrong-type: You can't set a cape as avatar. success: New avatar was set successfully. + reset: Reset Avatar password: title: Change Password diff --git a/resources/views/user/profile.twig b/resources/views/user/profile.twig index b8cd6ad1..e592bc26 100644 --- a/resources/views/user/profile.twig +++ b/resources/views/user/profile.twig @@ -2,6 +2,156 @@ {% block title %}{{ trans('general.profile') }}{% endblock %} +{% block content %} + + + + + + {{ trans('user.profile.avatar.title') }} + + + + {{ trans('user.profile.avatar.notice')|raw }} + + + + + + + + {{ trans('user.profile.password.title') }} + + + + + {{ trans('user.profile.password.old') }} + + + + + {{ trans('user.profile.password.new') }} + + + + + {{ trans('user.profile.password.confirm') }} + + + + + + + + + + + {{ trans('user.profile.nickname.title') }} + + + + + + + + + + + + + + {{ trans('user.profile.email.title') }} + + + + + + + + + + + + + + + + + {{ trans('user.profile.delete.title') }} + + + + {% if user.admin %} + {{ trans('user.profile.delete.admin') }} + + {{ trans('user.profile.delete.button') }} + + {% else %} + {{ trans('user.profile.delete.notice', { site: site_name }) }} + + {{ trans('user.profile.delete.button') }} + + {% endif %} + + + + + + + + + + + {{ trans('user.profile.delete.modal-title') }} + + + × + + + + {{ trans('user.profile.delete.modal-notice')|nl2br|raw }} + + + + + + + +{% endblock %} + {% block before_foot %}
{{ trans('user.profile.delete.admin') }}
{{ trans('user.profile.delete.notice', { site: site_name }) }}