Merge pull request #1125 from gradido/login_admin_interface

Login admin interface
This commit is contained in:
Moriz Wahl 2021-11-24 21:23:23 +01:00 committed by GitHub
commit 67594ce6ee
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 446 additions and 97 deletions

View File

@ -441,7 +441,7 @@ jobs:
report_name: Coverage Admin Interface
type: lcov
result_path: ./coverage/lcov.info
min_coverage: 47
min_coverage: 51
token: ${{ github.token }}
##############################################################################

3
admin/.env.dist Normal file
View File

@ -0,0 +1,3 @@
GRAPHQL_URI=http://localhost:4000/graphql
WALLET_AUTH_URL=http://localhost/vue/authenticate?token=$1
DEBUG_DISABLE_AUTH=false

View File

@ -7,11 +7,19 @@ const stubs = {
RouterView: true,
}
const mocks = {
$store: {
state: {
token: null,
},
},
}
describe('App', () => {
let wrapper
const Wrapper = () => {
return shallowMount(App, { localVue, stubs })
return shallowMount(App, { localVue, stubs, mocks })
}
describe('shallowMount', () => {

View File

@ -1,19 +1,15 @@
<template>
<div id="app">
<nav-bar class="wrapper-nav" />
<router-view class="wrapper p-3"></router-view>
<content-footer />
<default-layout v-if="$store.state.token" />
<router-view v-else></router-view>
</div>
</template>
<script>
import NavBar from '@/components/NavBar.vue'
import ContentFooter from '@/components/ContentFooter.vue'
import defaultLayout from '@/layouts/defaultLayout.vue'
export default {
name: 'App',
components: {
NavBar,
ContentFooter,
},
name: 'app',
components: { defaultLayout },
}
</script>

View File

@ -16,15 +16,43 @@
>
| {{ $store.state.openCreations }} offene Schöpfungen
</b-nav-item>
<b-nav-item @click="wallet">Wallet</b-nav-item>
<b-nav-item @click="logout">Logout</b-nav-item>
<!-- <b-nav-item v-show="open < 1" to="/creation-confirm">| keine offene Schöpfungen</b-nav-item> -->
</b-navbar-nav>
</b-collapse>
<b-navbar-brand href="http://localhost:3000/vue/login">Profilbereich</b-navbar-brand>
</b-navbar>
</div>
</template>
<script>
import CONFIG from '../config'
export default {
name: 'navbar',
methods: {
logout() {
// TODO
// this.$emit('logout')
/* this.$apollo
.query({
query: logout,
})
.then(() => {
this.$store.dispatch('logout')
this.$router.push('/logout')
})
.catch(() => {
this.$store.dispatch('logout')
if (this.$router.currentRoute.path !== '/logout') this.$router.push('/logout')
})
*/
this.$store.dispatch('logout')
this.$router.push('/logout')
},
wallet() {
window.location = CONFIG.WALLET_AUTH_URL.replace('$1', this.$store.state.token)
this.$store.dispatch('logout') // logout without redirect
},
},
}
</script>

View File

@ -17,8 +17,13 @@ const environment = {
PRODUCTION: process.env.NODE_ENV === 'production' || false,
}
const server = {
const endpoints = {
GRAPHQL_URI: process.env.GRAPHQL_URI || 'http://localhost:4000/graphql',
WALLET_AUTH_URL: process.env.WALLET_AUTH_URL || 'http://localhost/vue/authenticate?token=$1',
}
const debug = {
DEBUG_DISABLE_AUTH: process.env.DEBUG_DISABLE_AUTH === 'true' || false,
}
const options = {}
@ -26,8 +31,9 @@ const options = {}
const CONFIG = {
...version,
...environment,
...server,
...endpoints,
...options,
...debug,
}
export default CONFIG

View File

@ -0,0 +1,19 @@
<template>
<div>
<nav-bar class="wrapper-nav" />
<router-view class="wrapper p-3"></router-view>
<content-footer />
</div>
</template>
<script>
import NavBar from '@/components/NavBar.vue'
import ContentFooter from '@/components/ContentFooter.vue'
export default {
name: 'defaultLayout',
components: {
NavBar,
ContentFooter,
},
}
</script>

View File

@ -1,7 +1,25 @@
import CONFIG from '../config'
const addNavigationGuards = (router, store) => {
// store token on `authenticate`
router.beforeEach((to, from, next) => {
// handle authentication
if (to.meta.requiresAuth && !store.state.token) {
if (to.path === '/authenticate' && to.query && to.query.token) {
// TODO verify user to get user data
store.commit('token', to.query.token)
next({ path: '/' })
} else {
next()
}
})
// protect all routes but `not-found`
router.beforeEach((to, from, next) => {
if (
!CONFIG.DEBUG_DISABLE_AUTH && // we did not disabled the auth module for debug purposes
!store.state.token && // we do not have a token
to.path !== '/not-found' && // we are not on `not-found`
to.path !== '/logout' // we are not on `logout`
) {
next({ path: '/not-found' })
} else {
next()

View File

@ -0,0 +1,64 @@
import addNavigationGuards from './guards'
import router from './router'
const storeCommitMock = jest.fn()
const store = {
commit: storeCommitMock,
state: {
token: null,
},
}
addNavigationGuards(router, store)
describe('navigation guards', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('authenticate', () => {
const navGuard = router.beforeHooks[0]
const next = jest.fn()
describe('with valid token', () => {
it('commits the token to the store', async () => {
navGuard({ path: '/authenticate', query: { token: 'valid-token' } }, {}, next)
expect(storeCommitMock).toBeCalledWith('token', 'valid-token')
})
it('redirects to /', async () => {
navGuard({ path: '/authenticate', query: { token: 'valid-token' } }, {}, next)
expect(next).toBeCalledWith({ path: '/' })
})
})
describe('without valid token', () => {
it('does not commit the token to the store', async () => {
navGuard({ path: '/authenticate' }, {}, next)
expect(storeCommitMock).not.toBeCalledWith()
})
it('calls next withou arguments', async () => {
navGuard({ path: '/authenticate' }, {}, next)
expect(next).toBeCalledWith()
})
})
})
describe('protect all routes', () => {
const navGuard = router.beforeHooks[1]
const next = jest.fn()
it('redirects no not found with no token in store ', () => {
navGuard({ path: '/' }, {}, next)
expect(next).toBeCalledWith({ path: '/not-found' })
})
it('does not redirect when token in store', () => {
store.state.token = 'valid token'
navGuard({ path: '/' }, {}, next)
expect(next).toBeCalledWith()
})
})
})

View File

@ -44,19 +44,19 @@ describe('router', () => {
})
describe('routes', () => {
it('has seven routes defined', () => {
expect(routes).toHaveLength(7)
})
it('has "/overview" as default', async () => {
const component = await routes.find((r) => r.path === '/').component()
expect(component.default.name).toBe('overview')
})
it('has fourteen routes defined', () => {
expect(routes).toHaveLength(6)
})
describe('overview', () => {
it('loads the "Overview" component', async () => {
const component = await routes.find((r) => r.path === '/overview').component()
expect(component.default.name).toBe('overview')
describe('logout', () => {
it('loads the "NotFoundPage" component', async () => {
const component = await routes.find((r) => r.path === '/logout').component()
expect(component.default.name).toBe('not-found')
})
})

View File

@ -1,38 +1,27 @@
const routes = [
{
path: '/',
component: () => import('@/views/Overview.vue'),
meta: {
requiresAuth: true,
},
path: '/authenticate',
},
{
path: '/overview',
component: () => import('@/views/Overview.vue'),
meta: {
requiresAuth: true,
},
path: '/',
component: () => import('@/pages/Overview.vue'),
},
{
// TODO: Implement a "You are logged out"-Page
path: '/logout',
component: () => import('@/components/NotFoundPage.vue'),
},
{
path: '/user',
component: () => import('@/views/UserSearch.vue'),
meta: {
requiresAuth: true,
},
component: () => import('@/pages/UserSearch.vue'),
},
{
path: '/creation',
component: () => import('@/views/Creation.vue'),
meta: {
requiresAuth: true,
},
component: () => import('@/pages/Creation.vue'),
},
{
path: '/creation-confirm',
component: () => import('@/views/CreationConfirm.vue'),
meta: {
requiresAuth: true,
},
component: () => import('@/pages/CreationConfirm.vue'),
},
{
path: '*',

View File

@ -1,6 +1,7 @@
import Vuex from 'vuex'
import Vue from 'vue'
import createPersistedState from 'vuex-persistedstate'
import CONFIG from '../config'
Vue.use(Vuex)
@ -19,6 +20,13 @@ export const mutations = {
},
}
export const actions = {
logout: ({ commit, state }) => {
commit('token', null)
window.localStorage.clear()
},
}
const store = new Vuex.Store({
plugins: [
createPersistedState({
@ -26,12 +34,13 @@ const store = new Vuex.Store({
}),
],
state: {
token: 'some-valid-token',
token: CONFIG.DEBUG_DISABLE_AUTH ? 'validToken' : null,
moderator: 'Dertest Moderator',
openCreations: 0,
},
// Syncronous mutation of the state
mutations,
actions,
})
export default store

View File

@ -1,6 +1,11 @@
import { mutations } from './store'
import store, { mutations, actions } from './store'
const { token, openCreationsPlus, openCreationsMinus, resetOpenCreations } = mutations
const { logout } = actions
const CONFIG = {
DEBUG_DISABLE_AUTH: true,
}
describe('Vuex store', () => {
describe('mutations', () => {
@ -36,4 +41,43 @@ describe('Vuex store', () => {
})
})
})
describe('actions', () => {
describe('logout', () => {
const windowStorageMock = jest.fn()
const commit = jest.fn()
const state = {}
beforeEach(() => {
jest.clearAllMocks()
window.localStorage.clear = windowStorageMock
})
it('deletes the token in store', () => {
logout({ commit, state })
expect(commit).toBeCalledWith('token', null)
})
it.skip('clears the window local storage', () => {
expect(windowStorageMock).toBeCalled()
})
})
})
describe('state', () => {
describe('authentication enabled', () => {
it('has no token', () => {
expect(store.state.token).toBe(null)
})
})
describe('authentication enabled', () => {
beforeEach(() => {
CONFIG.DEBUG_DISABLE_AUTH = false
})
it.skip('has a token', () => {
expect(store.state.token).toBe('validToken')
})
})
})
})

View File

@ -20,6 +20,7 @@ export class User {
this.pubkey = json.public_hex
this.language = json.language
this.publisherId = json.publisher_id
this.isAdmin = json.isAdmin
}
}
@ -48,7 +49,7 @@ export class User {
@Field(() => number)
created: number
@Field(() => Boolean)
@Field(() =>>> Boolean)
emailChecked: boolean
@Field(() => Boolean)
@ -71,6 +72,9 @@ export class User {
@Field(() => Int, { nullable: true })
publisherId?: number
@Field(() => Boolean)
isAdmin: boolean
@Field(() => Boolean)
coinanimation: boolean

View File

@ -194,6 +194,69 @@ const SecretKeyCryptographyDecrypt = (encryptedMessage: Buffer, encryptionKey: B
@Resolver()
export class UserResolver {
/*
@Authorized()
@Query(() => User)
async verifyLogin(@Ctx() context: any): Promise<User> {
const loginUserRepository = getCustomRepository(LoginUserRepository)
loginUser = loginUserRepository.findByPubkeyHex()
const user = new User(result.data.user)
this.email = json.email
this.firstName = json.first_name
this.lastName = json.last_name
this.username = json.username
this.description = json.description
this.pubkey = json.public_hex
this.language = json.language
this.publisherId = json.publisher_id
this.isAdmin = json.isAdmin
const userSettingRepository = getCustomRepository(UserSettingRepository)
const coinanimation = await userSettingRepository
.readBoolean(userEntity.id, Setting.COIN_ANIMATION)
.catch((error) => {
throw new Error(error)
})
user.coinanimation = coinanimation
user.isAdmin = true // TODO implement
return user
}
*/
@Authorized()
@Query(() => User)
@UseMiddleware(klicktippNewsletterStateMiddleware)
async verifyLogin(@Ctx() context: any): Promise<User> {
// TODO refactor and do not have duplicate code with login(see below)
const userRepository = getCustomRepository(UserRepository)
const userEntity = await userRepository.findByPubkeyHex(context.pubKey)
const loginUserRepository = getCustomRepository(LoginUserRepository)
const loginUser = await loginUserRepository.findByEmail(userEntity.email)
const user = new User()
user.email = userEntity.email
user.firstName = userEntity.firstName
user.lastName = userEntity.lastName
user.username = userEntity.username
user.description = loginUser.description
user.pubkey = userEntity.pubkey.toString('hex')
user.language = loginUser.language
// Elopage Status & Stored PublisherId
user.hasElopage = await this.hasElopage(context)
// coinAnimation
const userSettingRepository = getCustomRepository(UserSettingRepository)
const coinanimation = await userSettingRepository
.readBoolean(userEntity.id, Setting.COIN_ANIMATION)
.catch((error) => {
throw new Error(error)
})
user.coinanimation = coinanimation
user.isAdmin = true // TODO implement
return user
}
@Query(() => User)
@UseMiddleware(klicktippNewsletterStateMiddleware)
async login(
@ -266,6 +329,7 @@ export class UserResolver {
throw new Error(error)
})
user.coinanimation = coinanimation
user.isAdmin = true // TODO implement
context.setHeaders.push({
key: 'token',

View File

@ -1,3 +1,4 @@
GRAPHQL_URI=http://localhost:4000/graphql
DEFAULT_PUBLISHER_ID=2896
//BUILD_COMMIT=0000000
#BUILD_COMMIT=0000000
ADMIN_AUTH_URL=http://localhost/admin/authenticate?token=$1

View File

@ -1,21 +0,0 @@
DEV README von Alex
default Page:
´´´
<template>
<div>default</div>
</template>
<script>
export default {
name: 'default',
data() {
return {}
},
methods: {},
watch: {},
}
</script>
´´´

View File

@ -1,9 +0,0 @@
<!--
IMPORTANT: Please use the following link to create a new issue:
https://www.gradido.net/new-issue/bootstrap-vue-gradido-wallet
**If your issue was not created using the app above, it will be closed immediately.**
-->

View File

@ -385,4 +385,13 @@ TODO: Update GDT-Server um paging und Zugriff auf alle Einträge zu erhalten, op
GET https://staging.gradido.net/state-balances/ajaxGdtTransactions
Liefert wenn alles in Ordnung ist:
wenn nicht type 7 dann "amount" in euro ansonsten in GDT
wenn nicht type 7 dann "amount" in euro ansonsten in GDT
## Additional Software
For `yarn locales` you will need `jq` to use it.
You can install it (on arch) via
```
sudo pacman -S jq
```

View File

@ -3,6 +3,8 @@ import SideBar from './SideBar'
const localVue = global.localVue
const storeDispatchMock = jest.fn()
describe('SideBar', () => {
let wrapper
@ -23,7 +25,7 @@ describe('SideBar', () => {
lastName: 'example',
hasElopage: false,
},
commit: jest.fn(),
dispatch: storeDispatchMock,
},
$i18n: {
locale: 'en',
@ -154,6 +156,42 @@ describe('SideBar', () => {
expect(wrapper.emitted('logout')).toEqual([[]])
})
})
describe('admin-area', () => {
it('is not visible when not an admin', () => {
expect(wrapper.findAll('li').at(1).text()).not.toBe('admin_area')
})
describe('logged in as admin', () => {
const assignLocationSpy = jest.fn()
beforeEach(async () => {
mocks.$store.state.isAdmin = true
mocks.$store.state.token = 'valid-token'
window.location.assign = assignLocationSpy
wrapper = Wrapper()
})
it('is visible', () => {
expect(wrapper.findAll('li').at(1).text()).toBe('admin_area')
})
describe('click on admin area', () => {
beforeEach(async () => {
await wrapper.findAll('li').at(1).find('a').trigger('click')
})
it('opens a new window when clicked', () => {
expect(assignLocationSpy).toHaveBeenCalledWith(
'http://localhost/admin/authenticate?token=valid-token',
)
})
it('dispatches logout to store', () => {
expect(storeDispatchMock).toHaveBeenCalledWith('logout')
})
})
})
})
})
})
})

View File

@ -45,11 +45,20 @@
<slot name="links"></slot>
</ul>
<hr class="my-2" />
<ul class="navbar-nav ml-3">
<li class="nav-item">
<a :href="getElopageLink()" class="nav-link" target="_blank">
{{ $t('members_area') }}&nbsp;
<b-badge v-if="!this.$store.state.hasElopage" pill variant="danger">!</b-badge>
<b-badge v-if="!$store.state.hasElopage" pill variant="danger">!</b-badge>
</a>
</li>
</ul>
<ul class="navbar-nav ml-3" v-if="$store.state.isAdmin">
<li class="nav-item">
<a class="nav-link pointer" @click="admin">
{{ $t('admin_area') }}
</a>
</li>
</ul>
@ -112,6 +121,10 @@ export default {
logout() {
this.$emit('logout')
},
admin() {
window.location.assign(CONFIG.ADMIN_AUTH_URL.replace('$1', this.$store.state.token))
this.$store.dispatch('logout') // logout without redirect
},
getElopageLink() {
const pId = this.$store.state.publisherId
? this.$store.state.publisherId

View File

@ -18,8 +18,9 @@ const environment = {
DEFAULT_PUBLISHER_ID: process.env.DEFAULT_PUBLISHER_ID || 2896,
}
const server = {
const endpoints = {
GRAPHQL_URI: process.env.GRAPHQL_URI || 'http://localhost:4000/graphql',
ADMIN_AUTH_URL: process.env.ADMIN_AUTH_URL || 'http://localhost/admin/authenticate?token=$1',
}
const options = {}
@ -27,7 +28,7 @@ const options = {}
const CONFIG = {
...version,
...environment,
...server,
...endpoints,
...options,
}

View File

@ -15,6 +15,27 @@ export const login = gql`
}
hasElopage
publisherId
isAdmin
}
}
`
export const verifyLogin = gql`
query {
verifyLogin {
email
username
firstName
lastName
language
description
coinanimation
klickTipp {
newsletterState
}
hasElopage
publisherId
isAdmin
}
}
`

View File

@ -1,4 +1,5 @@
{
"admin_area": "Adminbereich",
"back": "Zurück",
"community": {
"choose-another-community": "Eine andere Gemeinschaft auswählen",

View File

@ -1,4 +1,5 @@
{
"admin_area": "Admin's area",
"back": "Back",
"community": {
"choose-another-community": "Choose another community",

View File

@ -51,7 +51,7 @@ Vue.config.productionTip = false
loadAllRules(i18n)
addNavigationGuards(router, store)
addNavigationGuards(router, store, apolloProvider.defaultClient)
/* eslint-disable no-new */
new Vue({

View File

@ -1,12 +1,34 @@
const addNavigationGuards = (router, store) => {
import { verifyLogin } from '../graphql/queries'
const addNavigationGuards = (router, store, apollo) => {
// handle publisherId
router.beforeEach((to, from, next) => {
// handle publisherId
const publisherId = to.query.pid
if (publisherId) {
store.commit('publisherId', publisherId)
delete to.query.pid
}
// handle authentication
next()
})
// store token on authenticate
router.beforeEach(async (to, from, next) => {
if (to.path === '/authenticate' && to.query.token) {
// TODO verify user in order to get user data
store.commit('token', to.query.token)
const result = await apollo.query({
query: verifyLogin,
fetchPolicy: 'network-only',
})
store.dispatch('login', result.data.verifyLogin)
next({ path: '/overview' })
} else {
next()
}
})
// handle authentication
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !store.state.token) {
next({ path: '/login' })
} else {

View File

@ -30,7 +30,7 @@ describe('navigation guards', () => {
})
describe('authorization', () => {
const navGuard = router.beforeHooks[0]
const navGuard = router.beforeHooks[2]
const next = jest.fn()
it('redirects to login when not authorized', () => {

View File

@ -49,8 +49,8 @@ describe('router', () => {
expect(routes.find((r) => r.path === '/').redirect()).toEqual({ path: '/login' })
})
it('has fourteen routes defined', () => {
expect(routes).toHaveLength(14)
it('has fifteen routes defined', () => {
expect(routes).toHaveLength(15)
})
describe('overview', () => {

View File

@ -1,6 +1,9 @@
import NotFound from '@/views/NotFoundPage.vue'
const routes = [
{
path: '/authenticate',
},
{
path: '/',
redirect: (to) => {

View File

@ -34,6 +34,9 @@ export const mutations = {
if (isNaN(pubId)) pubId = null
state.publisherId = pubId
},
isAdmin: (state, isAdmin) => {
state.isAdmin = !!isAdmin
},
community: (state, community) => {
state.community = community
},
@ -57,6 +60,7 @@ export const actions = {
commit('newsletterState', data.klickTipp.newsletterState)
commit('hasElopage', data.hasElopage)
commit('publisherId', data.publisherId)
commit('isAdmin', data.isAdmin)
},
logout: ({ commit, state }) => {
commit('token', null)
@ -69,6 +73,7 @@ export const actions = {
commit('newsletterState', null)
commit('hasElopage', false)
commit('publisherId', null)
commit('isAdmin', false)
localStorage.clear()
},
}
@ -87,6 +92,7 @@ export const store = new Vuex.Store({
username: '',
description: '',
token: null,
isAdmin: false,
coinanimation: true,
newsletterState: null,
community: {

View File

@ -148,11 +148,12 @@ describe('Vuex store', () => {
},
hasElopage: false,
publisherId: 1234,
isAdmin: true,
}
it('calls ten commits', () => {
it('calls eleven commits', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenCalledTimes(10)
expect(commit).toHaveBeenCalledTimes(11)
})
it('commits email', () => {
@ -204,15 +205,20 @@ describe('Vuex store', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(10, 'publisherId', 1234)
})
it('commits isAdmin', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(11, 'isAdmin', true)
})
})
describe('logout', () => {
const commit = jest.fn()
const state = {}
it('calls ten commits', () => {
it('calls eleven commits', () => {
logout({ commit, state })
expect(commit).toHaveBeenCalledTimes(10)
expect(commit).toHaveBeenCalledTimes(11)
})
it('commits token', () => {
@ -265,6 +271,11 @@ describe('Vuex store', () => {
expect(commit).toHaveBeenNthCalledWith(10, 'publisherId', null)
})
it('commits isAdmin', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(11, 'isAdmin', false)
})
// how to get this working?
it.skip('calls localStorage.clear()', () => {
const clearStorageMock = jest.fn()