Refactor user profile page

to be static
This commit is contained in:
Pig Fang 2019-12-13 15:47:07 +08:00
parent 4af30bdbac
commit 56bd71c063
18 changed files with 487 additions and 448 deletions

View File

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

View File

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

View File

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

View File

@ -1,300 +0,0 @@
<template>
<div class="container-fluid">
<email-verification />
<div class="row">
<div class="col-md-6">
<div v-once class="card card-primary">
<div class="card-header">
<h3 v-t="'user.profile.avatar.title'" class="card-title" />
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="card-body" v-html="$t('user.profile.avatar.notice')" />
<div class="card-footer">
<button class="btn btn-primary" data-test="resetAvatar" @click="resetAvatar">
{{ $t('user.resetAvatar') }}
</button>
</div>
</div>
<form
class="card card-warning"
data-test="changePassword"
@submit.prevent="changePassword"
>
<div class="card-header">
<h3 v-t="'user.profile.password.title'" class="card-title" />
</div>
<div class="card-body">
<div class="form-group">
<label v-t="'user.profile.password.old'" />
<input
v-model="oldPassword"
type="password"
class="form-control"
required
>
</div>
<div class="form-group">
<label v-t="'user.profile.password.new'" />
<input
v-model="newPassword"
type="password"
class="form-control"
required
minlength="8"
maxlength="32"
>
</div>
<div class="form-group">
<label v-t="'user.profile.password.confirm'" />
<input
ref="confirmPassword"
v-model="confirmPassword"
type="password"
class="form-control"
required
minlength="8"
maxlength="32"
>
</div>
</div>
<div class="card-footer">
<button class="btn btn-primary" type="submit">
{{ $t('user.profile.password.button') }}
</button>
</div>
</form>
</div>
<div class="col-md-6">
<form
class="card card-primary"
data-test="changeNickName"
@submit.prevent="changeNickName"
>
<div class="card-header">
<h3 v-t="'user.profile.nickname.title'" class="card-title" />
</div>
<div class="card-body">
<div class="form-group">
<input
v-model="nickname"
type="text"
class="form-control"
:placeholder="$t('user.profile.nickname.rule')"
required
>
</div>
</div>
<div class="card-footer">
<button class="btn btn-primary" type="submit">
{{ $t('general.submit') }}
</button>
</div>
</form>
<form
class="card card-warning"
data-test="changeEmail"
@submit.prevent="changeEmail"
>
<div class="card-header">
<h3 v-t="'user.profile.email.title'" class="card-title" />
</div>
<div class="card-body">
<div class="form-group">
<input
ref="email"
v-model="email"
type="email"
class="form-control"
:placeholder="$t('user.profile.email.new')"
required
>
</div>
<div class="form-group">
<input
ref="currentPassword"
v-model="currentPassword"
type="password"
class="form-control"
:placeholder="$t('user.profile.email.password')"
required
>
</div>
</div>
<div class="card-footer">
<button class="btn btn-primary" type="submit">
{{ $t('user.profile.email.button') }}
</button>
</div>
</form>
<div class="card card-danger">
<div class="card-header">
<h3 v-t="'user.profile.delete.title'" class="card-title" />
</div>
<div class="card-body">
<template v-if="isAdmin">
<p v-t="'user.profile.delete.admin'" />
<button class="btn btn-danger" disabled>
{{ $t('user.profile.delete.button') }}
</button>
</template>
<template v-else>
<p v-t="{ path: 'user.profile.delete.notice', args: { site: siteName } }" />
<button
class="btn btn-danger"
data-toggle="modal"
data-target="#modal-delete-account"
>
{{ $t('user.profile.delete.button') }}
</button>
</template>
</div>
</div>
</div>
</div>
<form data-test="deleteAccount" @submit.prevent="deleteAccount">
<modal
id="modal-delete-account"
type="danger"
:title="$t('user.profile.delete.modal-title')"
flex-footer
>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-once v-html="nl2br($t('user.profile.delete.modal-notice'))" />
<br>
<input
v-model="deleteConfirm"
type="password"
class="form-control"
:placeholder="$t('user.profile.delete.password')"
required
>
<template #footer>
<button
v-t="'general.close'"
type="button"
class="btn btn-outline-light"
data-dismiss="modal"
/>
<button
v-t="'general.submit'"
type="submit"
class="btn btn-outline-light"
/>
</template>
</modal>
</form>
</div>
</template>
<script>
import EmailVerification from '../../components/EmailVerification.vue'
import Modal from '../../components/Modal.vue'
import emitMounted from '../../components/mixins/emitMounted'
import { showModal, toast } from '../../scripts/notify'
export default {
name: 'Profile',
components: {
EmailVerification,
Modal,
},
mixins: [
emitMounted,
],
data: () => ({
oldPassword: '',
newPassword: '',
confirmPassword: '',
nickname: '',
email: '',
currentPassword: '',
deleteConfirm: '',
siteName: blessing.site_name,
isAdmin: blessing.extra.admin,
}),
methods: {
nl2br: str => str.replace(/\n/g, '<br>'),
async resetAvatar() {
try {
await showModal({ text: this.$t('user.resetAvatarConfirm') })
} catch {
return
}
const { message } = await this.$http.post(
'/user/profile/avatar',
{ tid: 0 },
)
toast.success(message)
Array.from(document.querySelectorAll('[alt="User Image"]'))
.forEach(el => (el.src += `?${new Date().getTime()}`))
},
async changePassword() {
const {
oldPassword, newPassword, confirmPassword,
} = this
if (newPassword !== confirmPassword) {
toast.error(this.$t('auth.invalidConfirmPwd'))
this.$refs.confirmPassword.focus()
return
}
const { code, message } = await this.$http.post(
'/user/profile?action=password',
{ current_password: oldPassword, new_password: newPassword },
)
await showModal({ mode: 'alert', text: message })
if (code === 0) {
window.location = `${blessing.base_url}/auth/login`
}
},
async changeNickName() {
const { nickname } = this
const { code, message } = await this.$http.post(
'/user/profile?action=nickname',
{ new_nickname: nickname },
)
if (code === 0) {
Array
.from(document.querySelectorAll('[data-mark="nickname"]'))
.forEach(el => (el.textContent = nickname))
toast.success(message)
return
}
showModal({ mode: 'alert', text: message })
},
async changeEmail() {
const { email } = this
const { code, message } = await this.$http.post(
'/user/profile?action=email',
{ new_email: email, password: this.currentPassword },
)
await showModal({ mode: 'alert', text: message })
if (code === 0) {
window.location = `${blessing.base_url}/auth/login`
}
},
async deleteAccount() {
const { deleteConfirm: password } = this
const { code, message } = await this.$http.post(
'/user/profile?action=delete',
{ password },
)
await showModal({ mode: 'alert', text: message })
if (code === 0) {
window.location = `${blessing.base_url}/auth/login`
}
},
},
}
</script>

View File

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

View File

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

View File

@ -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<HTMLFormElement>('#change-password')
if (passwordForm) {
passwordForm.addEventListener('submit', passwordFormHandler)
}
const nicknameForm = document.querySelector<HTMLFormElement>('#change-nickname')
if (nicknameForm) {
nicknameForm.addEventListener('submit', nicknameFormHandler)
}
const emailForm = document.querySelector<HTMLFormElement>('#change-email')
if (emailForm) {
emailForm.addEventListener('submit', emailFormHandler)
}
const deleteAccountForm = document
.querySelector<HTMLFormElement>('#modal-delete-account')
if (deleteAccountForm) {
deleteAccountForm.addEventListener('submit', deleteAccountFormHandler)
}

View File

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

View File

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

View File

@ -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<HTMLImageElement>('[alt="User Image"]')
.forEach(el => {
el.src += `?${new Date().getTime()}`
})
}

View File

@ -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<Vue & { siteName: string, isAdmin: boolean }>(Profile)
expect(wrapper.vm.siteName).toBe('Blessing Skin')
expect(wrapper.vm.isAdmin).toBeTrue()
window.blessing.extra = { admin: false }
expect(mount<Vue & { isAdmin: boolean }>(Profile).vm.isAdmin).toBeFalse()
})
test('convert linebreak', () => {
const wrapper = mount<Vue & { nl2br(input: string): string }>(Profile)
expect(wrapper.vm.nl2br('a\nb\nc')).toBe('a<br>b<br>c')
})
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 += '<img alt="User Image" src="a">'
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 += '<span data-mark="nickname"></span>'
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' })
})

View File

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

View File

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

View File

@ -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 = '<span data-mark="nickname"></span>'
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')
})

View File

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

View File

@ -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 = '<img alt="User Image" src="a">'
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+$/)
})

View File

@ -119,6 +119,7 @@ profile:
notice: Click the gear icon "<i class="fa fa-cog"></i>" 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

View File

@ -2,6 +2,156 @@
{% block title %}{{ trans('general.profile') }}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="card card-primary">
<div class="card-header">
<h3 class="card-title">
{{ trans('user.profile.avatar.title') }}
</h3>
</div>
<div class="card-body">
{{ trans('user.profile.avatar.notice')|raw }}
</div>
<div class="card-footer">
<button class="btn btn-primary" id="reset-avatar">
{{ trans('user.profile.avatar.reset') }}
</button>
</div>
</div>
<form class="card card-warning" method="post" id="change-password">
<div class="card-header">
<h3 class="card-title">
{{ trans('user.profile.password.title') }}
</h3>
</div>
<div class="card-body">
<div class="form-group">
<label>{{ trans('user.profile.password.old') }}</label>
<input type="password" class="form-control" name="oldPassword" required>
</div>
<div class="form-group">
<label>{{ trans('user.profile.password.new') }}</label>
<input type="password" class="form-control" name="newPassword" required
minlength="8" maxlength="32">
</div>
<div class="form-group">
<label>{{ trans('user.profile.password.confirm') }}</label>
<input type="password" class="form-control" name="confirm" required
minlength="8" maxlength="32">
</div>
</div>
<div class="card-footer">
<button class="btn btn-primary" type="submit">
{{ trans('user.profile.password.button') }}
</button>
</div>
</form>
</div>
<div class="col-md-6">
<form class="card card-primary" method="post" id="change-nickname">
<div class="card-header">
<h3 class="card-title">
{{ trans('user.profile.nickname.title') }}
</h3>
</div>
<div class="card-body">
<div class="form-group">
<input type="text" class="form-control" name="nickname" required
placeholder="{{ trans('user.profile.nickname.rule') }}">
</div>
</div>
<div class="card-footer">
<button class="btn btn-primary" type="submit">
{{ trans('general.submit') }}
</button>
</div>
</form>
<form class="card card-warning" method="post" id="change-email">
<div class="card-header">
<h3 class="card-title">
{{ trans('user.profile.email.title') }}
</h3>
</div>
<div class="card-body">
<div class="form-group">
<input type="email" class="form-control" name="email" required
placeholder="{{ trans('user.profile.email.new') }}">
</div>
<div class="form-group">
<input type="password" class="form-control" name="password" required
placeholder="{{ trans('user.profile.email.password') }}">
</div>
</div>
<div class="card-footer">
<button class="btn btn-primary" type="submit">
{{ trans('user.profile.email.button') }}
</button>
</div>
</form>
<div class="card card-danger">
<div class="card-header">
<h3 class="card-title">
{{ trans('user.profile.delete.title') }}
</h3>
</div>
<div class="card-body">
{% if user.admin %}
<p>{{ trans('user.profile.delete.admin') }}</p>
<button class="btn btn-danger" disabled="disabled">
{{ trans('user.profile.delete.button') }}
</button>
{% else %}
<p>{{ trans('user.profile.delete.notice', { site: site_name }) }}</p>
<button
class="btn btn-danger"
data-toggle="modal"
data-target="#modal-delete-account"
>
{{ trans('user.profile.delete.button') }}
</button>
{% endif %}
</div>
</div>
</div>
</div>
<form id="modal-delete-account" class="modal fade" tabindex="-1" role="dialog" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered" role="document">
<div class="modal-content bg-danger">
<div class="modal-header">
<h5 class="modal-title">
{{ trans('user.profile.delete.modal-title') }}
</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">&times;</span>
</button>
</div>
<div class="modal-body">
<div>{{ trans('user.profile.delete.modal-notice')|nl2br|raw }}</div>
<br>
<input type="password" class="form-control" required name="password"
placeholder="{{ trans('user.profile.delete.password') }}">
</div>
<div class="modal-footer d-flex justify-content-between">
<button type="button" class="btn btn-outline-light" data-dismiss="modal">
{{ trans('general.close') }}
</button>
<button type="submit" class="btn btn-outline-light">
{{ trans('general.submit') }}
</button>
</div>
</div>
</div>
</form>
{% endblock %}
{% block before_foot %}
<script>
Object.defineProperty(blessing, 'extra', {