build OAuth mgmt page with React

This commit is contained in:
Pig Fang 2020-02-05 10:37:14 +08:00
parent de64694002
commit 93f64b034f
9 changed files with 527 additions and 348 deletions

View File

@ -0,0 +1,14 @@
import React from 'react'
interface Props {
title?: string
onClick: React.MouseEventHandler<HTMLAnchorElement>
}
const ButtonEdit: React.FC<Props> = props => (
<a href="#" title={props.title} className="ml-2" onClick={props.onClick}>
<i className="fas fa-edit"></i>
</a>
)
export default ButtonEdit

View File

@ -36,7 +36,7 @@ export default [
},
{
path: 'user/oauth/manage',
component: () => import('../views/user/OAuth.vue'),
react: () => import('../views/user/OAuth'),
el: '.content > .container-fluid',
},
{

View File

@ -1,207 +0,0 @@
<template>
<div class="container-fluid">
<button
type="primary"
class="btn-create-app btn btn-primary"
data-toggle="modal"
data-target="#modal-create"
>
{{ $t('user.oauth.create') }}
</button>
<vue-good-table
:rows="clients"
:columns="columns"
:search-options="tableOptions.search"
:pagination-options="tableOptions.pagination"
style-class="vgt-table striped"
>
<template #table-row="props">
<span v-if="props.column.field === 'name'">
{{ props.formattedRow[props.column.field] }}&nbsp;
<a
:title="$t('user.oauth.modifyName')"
href="#"
data-test="name"
@click="modifyName(props.row)"
>
<i class="fas fa-edit btn-edit" />
</a>
</span>
<span v-else-if="props.column.field === 'redirect'">
{{ props.formattedRow[props.column.field] }}&nbsp;
<a
:title="$t('user.oauth.modifyUrl')"
href="#"
data-test="callback"
@click="modifyCallback(props.row)"
>
<i class="fas fa-edit btn-edit" />
</a>
</span>
<span v-else-if="props.column.field === 'operations'">
<button class="btn btn-danger" data-test="remove" @click="remove(props.row)">
{{ $t('report.delete') }}
</button>
</span>
<span v-else>
{{ props.formattedRow[props.column.field] }}
</span>
</template>
</vue-good-table>
<modal id="modal-create" :title="$t('user.oauth.create')" @confirm="create">
<table class="table">
<tbody>
<tr>
<td v-t="'user.oauth.name'" class="key" />
<td class="value">
<input v-model="name" class="form-control" type="text">
</td>
</tr>
<tr>
<td v-t="'user.oauth.redirect'" class="key" />
<td class="value">
<input v-model="callback" class="form-control" type="text">
</td>
</tr>
</tbody>
</table>
</modal>
</div>
</template>
<script>
import { VueGoodTable } from 'vue-good-table'
import 'vue-good-table/dist/vue-good-table.min.css'
import Modal from '../../components/Modal.vue'
import tableOptions from '../../components/mixins/tableOptions'
import emitMounted from '../../components/mixins/emitMounted'
import { walkFetch, init } from '../../scripts/net'
import { showModal, toast } from '../../scripts/notify'
export default {
name: 'OAuthApps',
components: {
Modal,
VueGoodTable,
},
mixins: [
emitMounted,
tableOptions,
],
data() {
return {
name: '',
callback: '',
clients: [],
columns: [
{
field: 'id', label: this.$t('user.oauth.id'), type: 'number',
},
{ field: 'name', label: this.$t('user.oauth.name') },
{
field: 'secret',
label: this.$t('user.oauth.secret'),
sortable: false,
globalSearchDisabled: true,
},
{
field: 'redirect',
label: this.$t('user.oauth.redirect'),
sortable: false,
globalSearchDisabled: true,
},
{
field: 'operations',
label: this.$t('admin.operationsTitle'),
sortable: false,
globalSearchDisabled: true,
},
],
}
},
mounted() {
this.fetchData()
},
methods: {
async fetchData() {
this.clients = await this.$http.get('/oauth/clients')
},
async create() {
const client = await this.$http.post('/oauth/clients', {
name: this.name,
redirect: this.callback,
})
if (client.id) {
this.clients.unshift(client)
} else {
toast.error(client.message)
}
},
async modifyName(client) {
let name
try {
({ value: name } = await showModal({
mode: 'prompt',
title: this.$t('user.oauth.name'),
input: client.name,
}))
} catch {
return
}
await this.modify(client, { name })
},
async modifyCallback(client) {
let redirect
try {
({ value: redirect } = await showModal({
mode: 'prompt',
title: this.$t('user.oauth.redirect'),
input: client.redirect,
}))
} catch {
return
}
await this.modify(client, { redirect })
},
async modify(client, modified) {
const request = new Request(
`/oauth/clients/${client.id}`,
Object.assign({}, init, {
body: JSON.stringify(Object.assign({ name: client.name, redirect: client.redirect }, modified)),
method: 'PUT',
}),
)
request.headers.set('Content-Type', 'application/json')
const result = await walkFetch(request)
if (result.id) {
Object.assign(client, modified)
} else {
toast.error(result.message)
}
},
async remove(client) {
try {
await showModal({
text: this.$t('user.oauth.confirmRemove'),
okButtonType: 'danger',
})
} catch {
return
}
const request = new Request(
`/oauth/clients/${client.id}`,
Object.assign({}, init, { method: 'DELETE' }),
)
await walkFetch(request)
this.$delete(this.clients, this.clients.findIndex(({ id }) => id === client.id))
},
},
}
</script>
<style lang="stylus">
.btn-create-app
margin-bottom 5px
margin-right 10px
</style>

View File

@ -0,0 +1,70 @@
import React, { useState } from 'react'
import Modal from '../../../components/Modal'
import { trans } from '../../../scripts/i18n'
interface Props {
onCreate(name: string, redirect: string): Promise<void>
}
const ModalCreate: React.FC<Props> = props => {
const [name, setName] = useState('')
const [url, setUrl] = useState('')
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setName(event.target.value)
}
const handleUrlChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setUrl(event.target.value)
}
const handleComplete = () => {
props.onCreate(name, url)
}
const handleDismiss = () => {
setName('')
setUrl('')
}
return (
<Modal
id="modal-create"
onConfirm={handleComplete}
onDismiss={handleDismiss}
>
<table className="table">
<tbody>
<tr>
<td className="key">{trans('user.oauth.name')}</td>
<td className="value">
<input
value={name}
onChange={handleNameChange}
className="form-control"
placeholder={trans('user.oauth.name')}
type="text"
required
/>
</td>
</tr>
<tr>
<td className="key">{trans('user.oauth.redirect')}</td>
<td className="value">
<input
value={url}
onChange={handleUrlChange}
className="form-control"
placeholder={trans('user.oauth.redirect')}
type="url"
required
/>
</td>
</tr>
</tbody>
</table>
</Modal>
)
}
export default ModalCreate

View File

@ -0,0 +1,43 @@
import React from 'react'
import { trans } from '../../../scripts/i18n'
import ButtonEdit from '../../../components/ButtonEdit'
import { App } from './types'
interface Props {
app: App
onEditName: React.MouseEventHandler<HTMLAnchorElement>
onEditRedirect: React.MouseEventHandler<HTMLAnchorElement>
onDelete: React.MouseEventHandler<HTMLButtonElement>
}
const Row: React.FC<Props> = props => {
const { app } = props
return (
<tr>
<td>{app.id}</td>
<td>
<span>{app.name}</span>
<ButtonEdit
title={trans('user.oauth.modifyName')}
onClick={props.onEditName}
/>
</td>
<td>{app.secret}</td>
<td>
<span>{app.redirect}</span>
<ButtonEdit
title={trans('user.oauth.modifyUrl')}
onClick={props.onEditRedirect}
/>
</td>
<td>
<button className="btn btn-danger" onClick={props.onDelete}>
{trans('report.delete')}
</button>
</td>
</tr>
)
}
export default Row

View File

@ -0,0 +1,155 @@
import React, { useState, useEffect } from 'react'
import { hot } from 'react-hot-loader/root'
import { trans } from '../../../scripts/i18n'
import * as fetch from '../../../scripts/net'
import { showModal, toast } from '../../../scripts/notify'
import Loading from '../../../components/Loading'
import Row from './Row'
import ModalCreate from './ModalCreate'
import { App } from './types'
type Exception = {
message: string
}
const OAuth: React.FC = () => {
const [apps, setApps] = useState<App[]>([])
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
const getApps = async () => {
setIsLoading(true)
const allApps = await fetch.get<App[]>('/oauth/clients')
setApps(allApps)
setIsLoading(false)
}
getApps()
}, [])
const handleAdd = async (name: string, redirect: string) => {
const result = await fetch.post<App | Exception>('/oauth/clients', {
name,
redirect,
})
if ('id' in result) {
setApps(apps => [...apps, result])
} else {
toast.error(result.message)
}
}
const editName = async (app: App, index: number) => {
let name: string
try {
;({ value: name } = await showModal({
mode: 'prompt',
title: trans('user.oauth.name'),
input: app.name,
}))
} catch {
return
}
const result = await fetch.put<App | Exception>(
`/oauth/clients/${app.id}`,
{ ...app, name },
)
if ('id' in result) {
setApps(apps => {
apps[index].name = name
return apps.slice()
})
} else {
toast.error(result.message)
}
}
const editRedirect = async (app: App, index: number) => {
let redirect: string
try {
;({ value: redirect } = await showModal({
mode: 'prompt',
title: trans('user.oauth.redirect'),
input: app.redirect,
}))
} catch {
return
}
const result = await fetch.put<App | Exception>(
`/oauth/clients/${app.id}`,
{ ...app, redirect },
)
if ('id' in result) {
setApps(apps => {
apps[index].redirect = redirect
return apps.slice()
})
} else {
toast.error(result.message)
}
}
const handleDelete = async (app: App) => {
try {
await showModal({
text: trans('user.oauth.confirmRemove'),
okButtonType: 'danger',
})
} catch {
return
}
await fetch.del(`/oauth/clients/${app.id}`)
setApps(apps => apps.filter(a => a.id !== app.id))
}
return (
<>
<button
className="btn btn-primary"
data-toggle="modal"
data-target="#modal-create"
>
{trans('user.oauth.create')}
</button>
<div className="card mt-2">
<div className="card-body p-0">
<table className="table table-striped">
<thead>
<tr>
<th>{trans('user.oauth.id')}</th>
<th>{trans('user.oauth.name')}</th>
<th>{trans('user.oauth.secret')}</th>
<th>{trans('user.oauth.redirect')}</th>
<th>{trans('admin.operationsTitle')}</th>
</tr>
</thead>
<tbody>
{apps.length === 0 ? (
<tr>
<td className="text-center" colSpan={5}>
{isLoading ? <Loading /> : 'Nothing here.'}
</td>
</tr>
) : (
apps.map((app, i) => (
<Row
key={app.id}
app={app}
onEditName={() => editName(app, i)}
onEditRedirect={() => editRedirect(app, i)}
onDelete={() => handleDelete(app)}
/>
))
)}
</tbody>
</table>
</div>
</div>
<ModalCreate onCreate={handleAdd} />
</>
)
}
export default hot(OAuth)

View File

@ -0,0 +1,6 @@
export type App = {
id: number
name: string
secret: string
redirect: string
}

View File

@ -1,140 +0,0 @@
import Vue from 'vue'
import { mount } from '@vue/test-utils'
import { flushPromises } from '../../utils'
import { walkFetch } from '@/scripts/net'
import { showModal, toast } from '@/scripts/notify'
import Modal from '@/components/Modal.vue'
import OAuth from '@/views/user/OAuth.vue'
jest.mock('@/scripts/notify')
jest.mock('@/scripts/net', () => ({
walkFetch: jest.fn(),
init: {},
}))
test('basic render', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{ id: 1 },
])
const wrapper = mount(OAuth)
await flushPromises()
expect(wrapper.findAll('[data-test=remove]')).toHaveLength(1)
})
test('create app', async () => {
Vue.prototype.$http.get.mockResolvedValue([])
Vue.prototype.$http.post
.mockResolvedValueOnce({ message: 'fail' })
.mockResolvedValueOnce({ id: 1, name: 'name' })
const wrapper = mount(OAuth)
await flushPromises()
const modal = wrapper.find(Modal)
const inputs = wrapper.findAll('.value')
inputs.at(0).find('input')
.setValue('name')
inputs.at(1).find('input')
.setValue('https://example.com/')
modal.vm.$emit('confirm')
await flushPromises()
expect(Vue.prototype.$http.post).toBeCalledWith(
'/oauth/clients',
{ name: 'name', redirect: 'https://example.com/' },
)
expect(toast.error).toBeCalledWith('fail')
modal.vm.$emit('confirm')
await flushPromises()
expect(wrapper.text()).toContain('name')
})
test('modify name', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{ id: 1, name: 'old' },
])
walkFetch
.mockResolvedValueOnce({ message: 'fail' })
.mockResolvedValueOnce({ id: 1, name: 'new-name' })
showModal
.mockRejectedValueOnce(null)
.mockResolvedValue({ value: 'new-name' })
const wrapper = mount(OAuth)
await flushPromises()
const button = wrapper.find('[data-test=name]')
button.trigger('click')
await flushPromises()
expect(walkFetch).not.toBeCalled()
button.trigger('click')
await flushPromises()
expect(walkFetch).toBeCalledWith(
expect.objectContaining({
url: '/oauth/clients/1',
body: JSON.stringify({ name: 'new-name' }),
method: 'PUT',
}),
)
expect(toast.error).toBeCalledWith('fail')
button.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('new-name')
})
test('modify redirect', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{ id: 1, redirect: 'https://example.com/' },
])
walkFetch
.mockResolvedValueOnce({ message: 'fail' })
.mockResolvedValueOnce({ id: 1, redirect: 'https://example.net/' })
showModal
.mockRejectedValueOnce(null)
.mockResolvedValue({ value: 'https://example.net/' })
const wrapper = mount(OAuth)
await flushPromises()
const button = wrapper.find('[data-test=callback]')
button.trigger('click')
await flushPromises()
expect(walkFetch).not.toBeCalled()
button.trigger('click')
await flushPromises()
expect(walkFetch).toBeCalledWith(
expect.objectContaining({
url: '/oauth/clients/1',
body: JSON.stringify({ redirect: 'https://example.net/' }),
method: 'PUT',
}),
)
expect(toast.error).toBeCalledWith('fail')
button.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('https://example.net/')
})
test('remove app', async () => {
Vue.prototype.$http.get.mockResolvedValue([
{ id: 1, name: 'name' },
])
showModal
.mockRejectedValueOnce(null)
.mockResolvedValue({ value: '' })
const wrapper = mount(OAuth)
await flushPromises()
const button = wrapper.find('[data-test=remove]')
button.trigger('click')
await flushPromises()
expect(walkFetch).not.toBeCalled()
button.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('No data')
})

View File

@ -0,0 +1,238 @@
import React from 'react'
import { render, fireEvent, wait } from '@testing-library/react'
import * as fetch from '@/scripts/net'
import { trans } from '@/scripts/i18n'
import { toast, showModal } from '@/scripts/notify'
import OAuth from '@/views/user/OAuth'
import { App } from '@/views/user/OAuth/types'
jest.mock('@/scripts/net')
jest.mock('@/scripts/notify')
const example: App = {
id: 1,
name: 'My App',
redirect: 'http://url.test/',
secret: 'abc',
}
test('loading data', () => {
fetch.get.mockResolvedValue([])
const { queryByTitle } = render(<OAuth />)
expect(queryByTitle('Loading...')).toBeInTheDocument()
})
describe('create app', () => {
beforeEach(() => {
fetch.get.mockResolvedValue([])
})
it('succeeded', async () => {
fetch.post.mockResolvedValue(example)
const { getByPlaceholderText, getByText, queryByText } = render(<OAuth />)
await wait()
fireEvent.click(getByText(trans('user.oauth.create')))
fireEvent.input(getByPlaceholderText(trans('user.oauth.name')), {
target: { value: 'My App' },
})
fireEvent.input(getByPlaceholderText(trans('user.oauth.redirect')), {
target: { value: 'http://url.test/' },
})
fireEvent.click(getByText(trans('general.confirm')))
await wait()
expect(fetch.post).toBeCalledWith('/oauth/clients', {
name: 'My App',
redirect: 'http://url.test/',
})
expect(queryByText(example.id.toString())).toBeInTheDocument()
expect(queryByText(example.name)).toBeInTheDocument()
expect(queryByText(example.redirect)).toBeInTheDocument()
expect(queryByText(example.secret)).toBeInTheDocument()
})
it('failed', async () => {
fetch.post.mockResolvedValue({ message: 'exception' })
const { getByPlaceholderText, getByText, queryByText } = render(<OAuth />)
await wait()
fireEvent.click(getByText(trans('user.oauth.create')))
fireEvent.input(getByPlaceholderText(trans('user.oauth.name')), {
target: { value: 'My App' },
})
fireEvent.input(getByPlaceholderText(trans('user.oauth.redirect')), {
target: { value: 'http://url.test/' },
})
fireEvent.click(getByText(trans('general.confirm')))
await wait()
expect(fetch.post).toBeCalledWith('/oauth/clients', {
name: 'My App',
redirect: 'http://url.test/',
})
expect(toast.error).toBeCalledWith('exception')
expect(queryByText(example.name)).not.toBeInTheDocument()
expect(queryByText(example.redirect)).not.toBeInTheDocument()
})
it('cancel dialog', async () => {
const { getByPlaceholderText, getByText } = render(<OAuth />)
await wait()
fireEvent.click(getByText(trans('user.oauth.create')))
fireEvent.input(getByPlaceholderText(trans('user.oauth.name')), {
target: { value: 'My App' },
})
fireEvent.input(getByPlaceholderText(trans('user.oauth.redirect')), {
target: { value: 'http://url.test/' },
})
fireEvent.click(getByText(trans('general.cancel')))
await wait()
expect(fetch.post).not.toBeCalled()
fireEvent.click(getByText(trans('user.oauth.create')))
expect(getByPlaceholderText(trans('user.oauth.name'))).toHaveValue('')
expect(getByPlaceholderText(trans('user.oauth.redirect'))).toHaveValue('')
})
})
describe('edit app', () => {
beforeEach(() => {
fetch.get.mockResolvedValue([example])
})
describe('edit name', () => {
it('succeeded', async () => {
fetch.put.mockResolvedValue({ ...example, name: 'new name' })
showModal.mockResolvedValue({ value: 'new name' })
const { getByTitle, queryByText } = render(<OAuth />)
await wait()
fireEvent.click(getByTitle(trans('user.oauth.modifyName')))
await wait()
expect(fetch.put).toBeCalledWith(`/oauth/clients/${example.id}`, {
...example,
name: 'new name',
})
expect(queryByText('new name')).toBeInTheDocument()
})
it('failed', async () => {
fetch.put.mockResolvedValue({ message: 'exception' })
showModal.mockResolvedValue({ value: 'new name' })
const { getByTitle, queryByText } = render(<OAuth />)
await wait()
fireEvent.click(getByTitle(trans('user.oauth.modifyName')))
await wait()
expect(fetch.put).toBeCalledWith(`/oauth/clients/${example.id}`, {
...example,
name: 'new name',
})
expect(queryByText(example.name)).toBeInTheDocument()
})
it('cancel dialog', async () => {
showModal.mockRejectedValue(null)
const { getByTitle, queryByText } = render(<OAuth />)
await wait()
fireEvent.click(getByTitle(trans('user.oauth.modifyName')))
await wait()
expect(fetch.put).not.toBeCalled()
expect(queryByText(example.name)).toBeInTheDocument()
})
})
describe('edit redirect url', () => {
it('succeeded', async () => {
fetch.put.mockResolvedValue({ ...example, redirect: 'http://new.test/' })
showModal.mockResolvedValue({ value: 'http://new.test/' })
const { getByTitle, queryByText } = render(<OAuth />)
await wait()
fireEvent.click(getByTitle(trans('user.oauth.modifyUrl')))
await wait()
expect(fetch.put).toBeCalledWith(`/oauth/clients/${example.id}`, {
...example,
redirect: 'http://new.test/',
})
expect(queryByText('http://new.test/')).toBeInTheDocument()
})
it('failed', async () => {
fetch.put.mockResolvedValue({ message: 'exception' })
showModal.mockResolvedValue({ value: 'http://new.test/' })
const { getByTitle, queryByText } = render(<OAuth />)
await wait()
fireEvent.click(getByTitle(trans('user.oauth.modifyUrl')))
await wait()
expect(fetch.put).toBeCalledWith(`/oauth/clients/${example.id}`, {
...example,
redirect: 'http://new.test/',
})
expect(toast.error).toBeCalledWith('exception')
expect(queryByText(example.redirect)).toBeInTheDocument()
})
it('cancel dialog', async () => {
showModal.mockRejectedValue(null)
const { getByTitle, queryByText } = render(<OAuth />)
await wait()
fireEvent.click(getByTitle(trans('user.oauth.modifyUrl')))
await wait()
expect(fetch.put).not.toBeCalled()
expect(queryByText(example.redirect)).toBeInTheDocument()
})
})
})
describe('delete app', () => {
beforeEach(() => {
fetch.get.mockResolvedValue([example])
})
it('succeeded', async () => {
showModal.mockResolvedValue({ value: '' })
const { getByText, queryByText } = render(<OAuth />)
await wait()
fireEvent.click(getByText(trans('report.delete')))
await wait()
expect(fetch.del).toBeCalledWith(`/oauth/clients/${example.id}`)
expect(queryByText(example.name)).not.toBeInTheDocument()
expect(queryByText(example.redirect)).not.toBeInTheDocument()
})
it('cancel dialog', async () => {
showModal.mockRejectedValue(null)
const { getByText, queryByText } = render(<OAuth />)
await wait()
fireEvent.click(getByText(trans('report.delete')))
await wait()
expect(fetch.post).not.toBeCalled()
expect(queryByText(example.name)).toBeInTheDocument()
expect(queryByText(example.redirect)).toBeInTheDocument()
})
})