Merge branch 'master' into setup-seeding

This commit is contained in:
Moriz Wahl 2021-11-26 08:48:25 +01:00
commit 2ca75ba8fc
88 changed files with 2658 additions and 256 deletions

View File

@ -441,7 +441,7 @@ jobs:
report_name: Coverage Admin Interface
type: lcov
result_path: ./coverage/lcov.info
min_coverage: 65
min_coverage: 51
token: ${{ github.token }}
##############################################################################
@ -491,7 +491,7 @@ jobs:
report_name: Coverage Backend
type: lcov
result_path: ./backend/coverage/lcov.info
min_coverage: 38
min_coverage: 37
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

@ -1,6 +1,11 @@
module.exports = {
verbose: true,
collectCoverageFrom: ['src/**/*.{js,vue}', '!**/node_modules/**', '!**/?(*.)+(spec|test).js?(x)'],
collectCoverageFrom: [
'src/**/*.{js,vue}',
'!**/node_modules/**',
'!src/assets/**',
'!**/?(*.)+(spec|test).js?(x)',
],
moduleFileExtensions: [
'js',
// 'jsx',

View File

@ -32,15 +32,19 @@
"core-js": "^3.6.5",
"dotenv-webpack": "^7.0.3",
"graphql": "^15.6.1",
"identity-obj-proxy": "^3.0.0",
"jest": "26.6.3",
"moment": "^2.29.1",
"regenerator-runtime": "^0.13.9",
"stats-webpack-plugin": "^0.7.0",
"vue": "^2.6.11",
"vue-apollo": "^3.0.8",
"vue-i18n": "^8.26.5",
"vue-jest": "^3.0.7",
"vue-moment": "^4.1.0",
"vue-router": "^3.5.3",
"vuex": "^3.6.2"
"vuex": "^3.6.2",
"vuex-persistedstate": "^4.1.0"
},
"devDependencies": {
"@babel/eslint-parser": "^7.15.8",

View File

@ -1,43 +1,28 @@
import { mount } from '@vue/test-utils'
import { shallowMount } from '@vue/test-utils'
import App from './App'
const localVue = global.localVue
const storeCommitMock = jest.fn()
const stubs = {
RouterView: true,
}
const mocks = {
$store: {
commit: storeCommitMock,
state: {
token: null,
},
},
}
const localStorageMock = (() => {
let store = {}
return {
getItem: (key) => {
return store[key] || null
},
setItem: (key, value) => {
store[key] = value.toString()
},
removeItem: (key) => {
delete store[key]
},
clear: () => {
store = {}
},
}
})()
describe('App', () => {
let wrapper
const Wrapper = () => {
return mount(App, { localVue, mocks })
return shallowMount(App, { localVue, stubs, mocks })
}
describe('mount', () => {
describe('shallowMount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
@ -46,23 +31,4 @@ describe('App', () => {
expect(wrapper.find('div#app').exists()).toBeTruthy()
})
})
describe('window localStorage is undefined', () => {
it('does not commit a token to the store', () => {
expect(storeCommitMock).not.toBeCalled()
})
})
describe('with token in local storage', () => {
beforeEach(() => {
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
})
window.localStorage.setItem('vuex', JSON.stringify({ token: 1234 }))
})
it.skip('commits the token to the store', () => {
expect(storeCommitMock).toBeCalledWith('token', 1234)
})
})
})

View File

@ -1,9 +1,15 @@
<template>
<div id="app"></div>
<div id="app">
<default-layout v-if="$store.state.token" />
<router-view v-else></router-view>
</div>
</template>
<script>
import defaultLayout from '@/layouts/defaultLayout.vue'
export default {
name: 'App',
name: 'app',
components: { defaultLayout },
}
</script>

View File

@ -0,0 +1 @@
module.exports = {}

View File

@ -0,0 +1,15 @@
<template>
<div class="">
<hr />
<br />
<div class="text-center">
Gradido Akademie Adminkonsole
<div><small>Version: 0.0.1</small></div>
</div>
</div>
</template>
<script>
export default {
name: 'ContentFooter',
}
</script>

View File

@ -0,0 +1,141 @@
import { mount } from '@vue/test-utils'
import CreationFormular from './CreationFormular.vue'
const localVue = global.localVue
const mocks = {
$moment: jest.fn(() => {
return {
format: jest.fn((m) => m),
subtract: jest.fn(() => {
return {
format: jest.fn((m) => m),
}
}),
}
}),
}
const propsData = {
type: '',
item: {},
creation: [],
itemsMassCreation: {},
}
describe('CreationFormular', () => {
let wrapper
const Wrapper = () => {
return mount(CreationFormular, { localVue, mocks, propsData })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV element with the class.component-creation-formular', () => {
expect(wrapper.find('.component-creation-formular').exists()).toBeTruthy()
})
describe('radio buttons to selcet month', () => {
it('has three radio buttons', () => {
expect(wrapper.findAll('input[type="radio"]').length).toBe(3)
})
describe('with mass creation', () => {
beforeEach(async () => {
jest.clearAllMocks()
await wrapper.setProps({ type: 'massCreation' })
})
describe('first radio button', () => {
beforeEach(async () => {
await wrapper.findAll('input[type="radio"]').at(0).setChecked()
})
it('emits update-radio-selected with index 0', () => {
expect(wrapper.emitted()['update-radio-selected']).toEqual([
[expect.arrayContaining([0])],
])
})
})
describe('second radio button', () => {
beforeEach(async () => {
await wrapper.findAll('input[type="radio"]').at(1).setChecked()
})
it('emits update-radio-selected with index 1', () => {
expect(wrapper.emitted()['update-radio-selected']).toEqual([
[expect.arrayContaining([1])],
])
})
})
describe('third radio button', () => {
beforeEach(async () => {
await wrapper.findAll('input[type="radio"]').at(2).setChecked()
})
it('emits update-radio-selected with index 2', () => {
expect(wrapper.emitted()['update-radio-selected']).toEqual([
[expect.arrayContaining([2])],
])
})
})
})
describe('with single creation', () => {
beforeEach(async () => {
jest.clearAllMocks()
await wrapper.setProps({ type: 'singleCreation', creation: [200, 400, 600] })
await wrapper.setData({ rangeMin: 180 })
})
describe('first radio button', () => {
beforeEach(async () => {
await wrapper.findAll('input[type="radio"]').at(0).setChecked()
})
it('sets rangeMin to 0', () => {
expect(wrapper.vm.rangeMin).toBe(0)
})
it('sets rangeMax to 200', () => {
expect(wrapper.vm.rangeMax).toBe(200)
})
})
describe('second radio button', () => {
beforeEach(async () => {
await wrapper.findAll('input[type="radio"]').at(1).setChecked()
})
it('sets rangeMin to 0', () => {
expect(wrapper.vm.rangeMin).toBe(0)
})
it('sets rangeMax to 400', () => {
expect(wrapper.vm.rangeMax).toBe(400)
})
})
describe('third radio button', () => {
beforeEach(async () => {
await wrapper.findAll('input[type="radio"]').at(2).setChecked()
})
it('sets rangeMin to 0', () => {
expect(wrapper.vm.rangeMin).toBe(0)
})
it('sets rangeMax to 400', () => {
expect(wrapper.vm.rangeMax).toBe(600)
})
})
})
})
})
})

View File

@ -0,0 +1,290 @@
<template>
<div class="component-creation-formular">
<div>
<h3>
{{
this.type === 'singleCreation'
? 'Einzelschöpfung für ' + item.firstName + ' ' + item.lastName + ''
: 'Mehrfachschöpfung für ' + Object.keys(this.itemsMassCreation).length + ' Mitglieder'
}}
{{ item }}
</h3>
<div v-show="this.type === 'massCreation' && Object.keys(this.itemsMassCreation).length <= 0">
Bitte wähle ein oder Mehrere Mitglieder aus für die du Schöpfen möchtest
</div>
</div>
<div
v-show="this.type === 'singleCreation' || Object.keys(this.itemsMassCreation).length > 0"
class="shadow p-3 mb-5 bg-white rounded"
>
<b-form ref="creationForm">
<b-row class="m-4">
<label>Monat Auswählen</label>
<b-col class="text-left">
<b-form-radio
v-model="radioSelected"
:value="beforeLastMonth"
size="lg"
@change="updateRadioSelected(beforeLastMonth, 0, creation[0])"
>
{{ beforeLastMonth.short }} {{ creation[0] != null ? creation[0] + ' GDD' : '' }}
</b-form-radio>
</b-col>
<b-col>
<b-form-radio
v-model="radioSelected"
:value="lastMonth"
size="lg"
@change="updateRadioSelected(lastMonth, 1, creation[1])"
>
{{ lastMonth.short }} {{ creation[1] != null ? creation[1] + ' GDD' : '' }}
</b-form-radio>
</b-col>
<b-col class="text-right">
<b-form-radio
v-model="radioSelected"
:value="currentMonth"
size="lg"
@change="updateRadioSelected(currentMonth, 2, creation[2])"
>
{{ currentMonth.short }} {{ creation[2] != null ? creation[2] + ' GDD' : '' }}
</b-form-radio>
</b-col>
</b-row>
<b-row class="m-4">
<label>Betrag Auswählen</label>
<b-input-group>
<template #append>
<b-input-group-text><strong class="text-danger">GDD</strong></b-input-group-text>
</template>
<b-form-input
type="number"
v-model="value"
:min="rangeMin"
:max="rangeMax"
></b-form-input>
</b-input-group>
<b-input
id="range-2"
class="mt-2"
v-model="value"
type="range"
:min="rangeMin"
:max="rangeMax"
step="10"
@load="checkFormForUpdate('range')"
></b-input>
</b-row>
<b-row class="m-4">
<label>Text eintragen</label>
<div>
<b-form-textarea
id="textarea-state"
v-model="text"
:state="text.length >= 10"
placeholder="Mindestens 10 Zeichen eingeben"
@load="checkFormForUpdate('text')"
rows="3"
></b-form-textarea>
</div>
</b-row>
<b-row class="m-4">
<b-col class="text-center">
<b-button type="reset" variant="danger" @click="$refs.creationForm.reset()">
zurücksetzen
</b-button>
</b-col>
<b-col class="text-center">
<div class="text-right">
<b-button
v-if="pagetype === 'PageCreationConfirm'"
type="button"
variant="success"
@click="submitCreation"
:disabled="radioSelected === '' || value <= 0 || text.length < 10"
>
Update Schöpfung ({{ type }},{{ pagetype }})
</b-button>
<b-button
v-else
type="button"
variant="success"
@click="submitCreation"
:disabled="radioSelected === '' || value <= 0 || text.length < 10"
>
Schöpfung einreichen ({{ type }})
</b-button>
</div>
</b-col>
</b-row>
</b-form>
</div>
</div>
</template>
<script>
export default {
name: 'CreationFormular',
props: {
type: {
type: String,
required: false,
},
pagetype: {
type: String,
required: false,
default: '',
},
item: {
type: Object,
required: false,
},
creationUserData: {
type: Object,
required: false,
},
creation: {
type: Array,
required: true,
},
itemsMassCreation: {
type: Object,
required: false,
},
},
data() {
return {
radioSelected: '',
text: '',
value: 0,
rangeMin: 0,
rangeMax: 1000,
currentMonth: {
short: this.$moment().format('MMMM'),
long: this.$moment().format('DD/MM/YYYY'),
},
lastMonth: {
short: this.$moment().subtract(1, 'month').format('MMMM'),
long: this.$moment().subtract(1, 'month').format('DD/MM/YYYY'),
},
beforeLastMonth: {
short: this.$moment().subtract(2, 'month').format('MMMM'),
long: this.$moment().subtract(2, 'month').format('DD/MM/YYYY'),
},
submitObj: null,
isdisabled: true,
}
},
methods: {
// Auswählen eines Zeitraumes
updateRadioSelected(name, index, openCreation) {
// Wenn Mehrfachschöpfung
if (this.type === 'massCreation') {
// An Creation.vue emitten und radioSelectedMass aktualisieren
this.$emit('update-radio-selected', [name, index])
}
// Wenn Einzelschöpfung
if (this.type === 'singleCreation') {
this.rangeMin = 0
// Der maximale offene Betrag an GDD die für ein User noch geschöpft werden kann
this.rangeMax = openCreation
}
},
checkFormForUpdate(input) {
switch (input) {
case 'text':
this.text = this.creationUserData.text
break
case 'range':
this.value = this.creationUserData.creationGdd
break
default:
// TODO: Toast
alert("I don't know such values")
}
},
submitCreation() {
// Formular Prüfen ob ein Zeitraum ausgewählt wurde. Ansonsten abbrechen und Hinweis anzeigen
if (this.radioSelected === '') {
return alert('Bitte wähle einen Zeitraum!')
}
// Formular Prüfen ob der GDD Betrag grösser 0 ist. Ansonsten abbrechen und Hinweis anzeigen
if (this.value === 0) {
return alert('Bitte gib einen GDD Betrag an!')
}
// Formular Prüfen ob der Text vorhanden ist. Ansonsten abbrechen und Hinweis anzeigen
if (this.text === '') {
return alert('Bitte gib einen Text ein!')
}
// Formular Prüfen ob der Text länger als 10 Zeichen hat. Ansonsten abbrechen und Hinweis anzeigen
if (this.text.length < 10) {
return alert('Bitte gib einen Text ein der länger als 10 Zeichen ist!')
}
if (this.type === 'massCreation') {
// Die anzahl der Mitglieder aus der Mehrfachschöpfung
const i = Object.keys(this.itemsMassCreation).length
// hinweis das eine Mehrfachschöpfung ausgeführt wird an (Anzahl der MItgleider an die geschöpft wird)
alert('SUBMIT CREATION => ' + this.type + ' >> für VIELE ' + i + ' Mitglieder')
this.submitObj = [
{
item: this.itemsMassCreation,
datum: this.radioSelected,
amount: this.value,
text: this.text,
moderator: this.$store.state.moderator,
},
]
alert('MehrfachSCHÖPFUNG ABSENDEN FÜR >> ' + i + ' Mitglieder')
// $store - offene Schöpfungen hochzählen
this.$store.commit('openCreationsPlus', i)
// lösche alle Mitglieder aus der MehrfachSchöpfungsListe nach dem alle Mehrfachschpfungen zum bestätigen gesendet wurden.
this.$emit('remove-all-bookmark')
}
if (this.type === 'singleCreation') {
// hinweis das eine einzelne schöpfung ausgeführt wird an (Vorname)
alert('SUBMIT CREATION => ' + this.type + ' >> für ' + this.item.firstName + '')
// erstellen eines Arrays (submitObj) mit allen Daten
this.submitObj = [
{
item: this.item,
datum: this.radioSelected.long,
amount: this.value,
text: this.text,
moderator: this.$store.state.moderator,
},
]
if (this.pagetype === 'PageCreationConfirm') {
// hinweis das eine ein einzelne Schöpfung abgesendet wird an (email)
alert('UPDATE EINZEL SCHÖPFUNG ABSENDEN FÜR >> ')
// umschreiben, update eine bestehende Schöpfung eine
this.$emit('update-creation-data', {
datum: this.radioSelected.long,
creationGdd: this.value,
text: this.text,
})
} else {
// hinweis das eine ein einzelne Schöpfung abgesendet wird an (email)
alert('EINZEL SCHÖPFUNG ABSENDEN FÜR >> ' + this.item.firstName + '')
// $store - offene Schöpfungen hochzählen
this.$store.commit('openCreationsPlus', 1)
}
}
// das absendeergebniss im string ansehen
alert(JSON.stringify(this.submitObj))
// das submitObj zurücksetzen
this.submitObj = null
// das creation Formular reseten
this.$refs.creationForm.reset()
// Den geschöpften Wert auf o setzen
this.value = 0
},
},
}
</script>

View File

@ -0,0 +1,30 @@
import { mount } from '@vue/test-utils'
import NavBar from './NavBar.vue'
const localVue = global.localVue
const mocks = {
$store: {
state: {
openCreations: 1,
},
},
}
describe('NavBar', () => {
let wrapper
const Wrapper = () => {
return mount(NavBar, { mocks, localVue })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV element with the class.component-nabvar', () => {
expect(wrapper.find('.component-nabvar').exists()).toBeTruthy()
})
})
})

View File

@ -0,0 +1,58 @@
<template>
<div class="component-nabvar">
<b-navbar toggleable="sm" type="dark" variant="success">
<b-navbar-brand to="/">Adminbereich</b-navbar-brand>
<b-navbar-toggle target="nav-collapse"></b-navbar-toggle>
<b-collapse id="nav-collapse" is-nav>
<b-navbar-nav>
<b-nav-item to="/user">Usersuche |</b-nav-item>
<b-nav-item to="/creation">Mehrfachschöpfung</b-nav-item>
<b-nav-item
v-show="$store.state.openCreations > 0"
class="h5 bg-danger"
to="/creation-confirm"
>
| {{ $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>
</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

@ -0,0 +1,29 @@
import { mount } from '@vue/test-utils'
import UserTable from './UserTable.vue'
const localVue = global.localVue
describe('UserTable', () => {
let wrapper
const propsData = {
type: 'Type',
itemsUser: [],
fieldsTable: [],
creation: [],
}
const Wrapper = () => {
return mount(UserTable, { localVue, propsData })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV element with the class.component-user-table', () => {
expect(wrapper.find('.component-user-table').exists()).toBeTruthy()
})
})
})

View File

@ -0,0 +1,254 @@
<template>
<div class="component-user-table">
<div v-show="overlay" id="overlay" class="">
<b-jumbotron class="bg-light p-4">
<template #header>{{ overlayText.header }}</template>
<template #lead>
{{ overlayText.text1 }}
</template>
<hr class="my-4" />
<p>
{{ overlayText.text2 }}
</p>
<b-button size="lg" variant="danger" class="m-3" @click="overlayCancel">
{{ overlayText.button_cancel }}
</b-button>
<b-button
size="lg"
variant="success"
class="m-3 text-right"
@click="overlayOK(overlayBookmarkType, overlayItem)"
>
{{ overlayText.button_ok }}
</b-button>
</b-jumbotron>
</div>
<b-table-lite
:items="itemsUser"
:fields="fieldsTable"
:filter="criteria"
caption-top
striped
hover
stacked="md"
>
<template #cell(edit_creation)="row">
<b-button
variant="info"
size="lg"
@click="editCreationUserTable(row, row.item)"
class="mr-2"
>
<b-icon v-if="row.detailsShowing" icon="x" aria-label="Help"></b-icon>
<b-icon v-else icon="pencil-square" aria-label="Help"></b-icon>
</b-button>
</template>
<template #cell(show_details)="row">
<b-button variant="info" size="lg" @click="row.toggleDetails" class="mr-2">
<b-icon v-if="row.detailsShowing" icon="eye-slash-fill" aria-label="Help"></b-icon>
<b-icon v-else icon="eye-fill" aria-label="Help"></b-icon>
</b-button>
</template>
<template #row-details="row">
<b-card class="shadow-lg p-3 mb-5 bg-white rounded">
<b-row class="mb-2">
<b-col></b-col>
</b-row>
<creation-formular
type="singleCreation"
:pagetype="type"
:creation="row.item.creation"
:item="row.item"
:creationUserData="creationData"
@update-creation-data="updateCreationData"
/>
<b-button size="sm" @click="row.toggleDetails">
<b-icon
:icon="type === 'PageCreationConfirm' ? 'x' : 'eye-slash-fill'"
aria-label="Help"
></b-icon>
Details verbergen von {{ row.item.firstName }} {{ row.item.lastName }}
</b-button>
</b-card>
</template>
<template #cell(bookmark)="row">
<b-button
variant="warning"
v-show="type === 'UserListSearch'"
size="md"
@click="bookmarkPush(row.item)"
class="mr-2"
>
<b-icon icon="plus" variant="success"></b-icon>
</b-button>
<b-button
variant="danger"
v-show="type === 'UserListMassCreation' || type === 'PageCreationConfirm'"
size="lg"
@click="overlayShow('remove', row.item)"
class="mr-2"
>
<b-icon icon="x" variant="light"></b-icon>
</b-button>
</template>
<template #cell(confirm)="row">
<b-button
variant="success"
v-show="type === 'PageCreationConfirm'"
size="lg"
@click="overlayShow('confirm', row.item)"
class="mr-2"
>
<b-icon icon="check" scale="2" variant=""></b-icon>
</b-button>
</template>
</b-table-lite>
</div>
</template>
<script>
import CreationFormular from '../components/CreationFormular.vue'
export default {
name: 'UserTable',
props: {
type: {
type: String,
required: true,
},
itemsUser: {
type: Array,
required: true,
},
fieldsTable: {
type: Array,
required: true,
},
criteria: {
type: String,
required: false,
default: '',
},
creation: {
type: Array,
required: false,
},
},
components: {
CreationFormular,
},
data() {
return {
creationData: {},
overlay: false,
overlayBookmarkType: '',
overlayItem: [],
overlayText: [
{
header: '-',
text1: '--',
text2: '---',
button_ok: 'OK',
button_cancel: 'Cancel',
},
],
}
},
methods: {
overlayShow(bookmarkType, item) {
this.overlay = true
this.overlayBookmarkType = bookmarkType
this.overlayItem = item
if (bookmarkType === 'remove') {
this.overlayText.header = 'Achtung! Schöpfung löschen!'
this.overlayText.text1 =
'Nach dem Löschen gibt es keine Möglichkeit mehr diesen Datensatz wiederherzustellen. Es wird aber der gesamte Vorgang in der Logdatei als Übersicht gespeichert.'
this.overlayText.text2 = 'Willst du die vorgespeicherte Schöpfung wirklich löschen? '
this.overlayText.button_ok = 'Ja, Schöpfung löschen!'
this.overlayText.button_cancel = 'Nein, nicht löschen.'
}
if (bookmarkType === 'confirm') {
this.overlayText.header = 'Schöpfung bestätigen!'
this.overlayText.text1 =
'Nach dem Speichern ist der Datensatz nicht mehr änderbar und kann auch nicht mehr gelöscht werden. Bitte überprüfe genau, dass alles stimmt.'
this.overlayText.text2 =
'Willst du diese vorgespeicherte Schöpfung wirklich vollziehen und entgültig speichern?'
this.overlayText.button_ok = 'Ja, Schöpfung speichern und bestätigen!'
this.overlayText.button_cancel = 'Nein, nicht speichern.'
}
},
overlayOK(bookmarkType, item) {
if (bookmarkType === 'remove') {
this.bookmarkRemove(item)
}
if (bookmarkType === 'confirm') {
this.bookmarkConfirm(item)
}
this.overlay = false
},
overlayCancel() {
this.overlay = false
},
bookmarkPush(item) {
this.$emit('update-item', item, 'push')
},
bookmarkRemove(item) {
if (this.type === 'UserListMassCreation') {
this.$emit('update-item', item, 'remove')
}
if (this.type === 'PageCreationConfirm') {
this.$emit('remove-confirm-result', item, 'remove')
}
},
bookmarkConfirm(item) {
alert('die schöpfung bestätigen und abschließen')
alert(JSON.stringify(item))
this.$emit('remove-confirm-result', item, 'remove')
},
editCreationUserTable(row, rowItem) {
alert('editCreationUserTable')
if (!row.detailsShowing) {
alert('offen edit loslegen')
// this.item = rowItem
this.creationData = rowItem
// alert(this.creationData)
}
row.toggleDetails()
},
updateCreationData(data) {
this.creationData = {
...data,
}
},
},
}
</script>
<style>
#overlay {
position: fixed;
display: flex;
align-items: center;
width: 100%;
height: 100%;
top: 0;
left: 0;
right: 0;
bottom: 0;
padding-left: 5%;
background-color: rgba(12, 11, 11, 0.781);
z-index: 1000000;
cursor: pointer;
}
</style>

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,12 @@
import gql from 'graphql-tag'
export const searchUsers = gql`
query ($searchText: String!) {
searchUsers(searchText: $searchText) {
firstName
lastName
email
creation
}
}
`

30
admin/src/i18n.test.js Normal file
View File

@ -0,0 +1,30 @@
import i18n from './i18n'
import VueI18n from 'vue-i18n'
jest.mock('vue-i18n')
describe('i18n', () => {
it('calls i18n with locale en', () => {
expect(VueI18n).toBeCalledWith(
expect.objectContaining({
locale: 'en',
}),
)
})
it('calls i18n with fallback locale en', () => {
expect(VueI18n).toBeCalledWith(
expect.objectContaining({
fallbackLocale: 'en',
}),
)
})
it('has a _t function', () => {
expect(i18n).toEqual(
expect.objectContaining({
_t: expect.anything(),
}),
)
})
})

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

@ -16,12 +16,17 @@ import VueApollo from 'vue-apollo'
import CONFIG from './config'
import { BootstrapVue } from 'bootstrap-vue'
import { BootstrapVue, IconsPlugin } from 'bootstrap-vue'
import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import moment from 'vue-moment'
const httpLink = new HttpLink({ uri: CONFIG.GRAPHQL_URI })
const authLink = new ApolloLink((operation, forward) => {
const token = store.state.token
operation.setContext({
headers: {
Authorization: token && token.length > 0 ? `Bearer ${token}` : '',
@ -52,9 +57,16 @@ const apolloProvider = new VueApollo({
Vue.use(BootstrapVue)
Vue.use(IconsPlugin)
Vue.use(moment)
Vue.use(VueApollo)
addNavigationGuards(router, store)
new Vue({
moment,
router,
store,
i18n,

View File

@ -5,10 +5,13 @@ import CONFIG from './config'
import Vue from 'vue'
import Vuex from 'vuex'
import VueI18n from 'vue-i18n'
import { BootstrapVue, IconsPlugin } from 'bootstrap-vue'
import moment from 'vue-moment'
jest.mock('vue')
jest.mock('vuex')
jest.mock('vue-i18n')
jest.mock('vue-moment')
const storeMock = jest.fn()
Vuex.Store = storeMock
@ -25,6 +28,16 @@ jest.mock('apollo-boost', () => {
}
})
jest.mock('bootstrap-vue', () => {
return {
__esModule: true,
BootstrapVue: jest.fn(),
IconsPlugin: jest.fn(() => {
return { concat: jest.fn() }
}),
}
})
describe('main', () => {
it('calls the HttpLink', () => {
expect(HttpLink).toBeCalledWith({ uri: CONFIG.GRAPHQL_URI })
@ -50,6 +63,18 @@ describe('main', () => {
expect(VueI18n).toBeCalled()
})
it.skip('calls BootstrapVue', () => {
expect(BootstrapVue).toBeCalled()
})
it.skip('calls IconsPlugin', () => {
expect(IconsPlugin).toBeCalled()
})
it.skip('calls Moment', () => {
expect(moment).toBeCalled()
})
it.skip('creates a store', () => {
expect(storeMock).toBeCalled()
})

View File

@ -0,0 +1,59 @@
import { mount } from '@vue/test-utils'
import Creation from './Creation.vue'
const localVue = global.localVue
const apolloQueryMock = jest.fn().mockResolvedValue({
data: {
searchUsers: [
{
firstName: 'Bibi',
lastName: 'Bloxberg',
email: 'bibi@bloxberg.de',
creation: [200, 400, 600],
},
],
},
})
const toastErrorMock = jest.fn()
const mocks = {
$apollo: {
query: apolloQueryMock,
},
$toasted: {
error: toastErrorMock,
},
}
describe('Creation', () => {
let wrapper
const Wrapper = () => {
return mount(Creation, { localVue, mocks })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV element with the class.creation', () => {
expect(wrapper.find('div.creation').exists()).toBeTruthy()
})
describe('apollo returns error', () => {
beforeEach(() => {
apolloQueryMock.mockRejectedValue({
message: 'Ouch',
})
wrapper = Wrapper()
})
it('toasts an error message', () => {
expect(toastErrorMock).toBeCalledWith('Ouch')
})
})
})
})

View File

@ -0,0 +1,143 @@
<template>
<div class="creation">
<b-row>
<b-col cols="12" lg="5">
<label>Usersuche</label>
<b-input
type="text"
v-model="criteria"
class="shadow p-3 mb-5 bg-white rounded"
placeholder="User suche"
></b-input>
<user-table
v-if="itemsList.length > 0"
type="UserListSearch"
:itemsUser="itemsList"
:fieldsTable="Searchfields"
:criteria="criteria"
:creation="creation"
@update-item="updateItem"
/>
</b-col>
<b-col cols="12" lg="7" class="shadow p-3 mb-5 rounded bg-info">
<user-table
v-if="massCreation.length > 0"
class="shadow p-3 mb-5 bg-white rounded"
type="UserListMassCreation"
:itemsUser="massCreation"
:fieldsTable="fields"
:criteria="null"
:creation="creation"
@update-item="updateItem"
/>
<creation-formular
v-if="massCreation.length > 0"
type="massCreation"
:creation="creation"
:itemsMassCreation="massCreation"
@update-radio-selected="updateRadioSelected"
@remove-all-bookmark="removeAllBookmark"
/>
</b-col>
</b-row>
</div>
</template>
<script>
import CreationFormular from '../components/CreationFormular.vue'
import UserTable from '../components/UserTable.vue'
import { searchUsers } from '../graphql/searchUsers'
export default {
name: 'Creation',
components: {
CreationFormular,
UserTable,
},
data() {
return {
showArrays: false,
Searchfields: [
{ key: 'bookmark', label: 'merken' },
{ key: 'firstName', label: 'Firstname' },
{ key: 'lastName', label: 'Lastname' },
{ key: 'creation', label: 'Creation' },
{ key: 'email', label: 'Email' },
],
fields: [
{ key: 'email', label: 'Email' },
{ key: 'firstName', label: 'Firstname' },
{ key: 'lastName', label: 'Lastname' },
{ key: 'creation', label: 'Creation' },
{ key: 'bookmark', label: 'löschen' },
],
itemsList: [],
massCreation: [],
radioSelectedMass: '',
criteria: '',
creation: [null, null, null],
}
},
async created() {
await this.getUsers()
},
methods: {
async getUsers() {
this.$apollo
.query({
query: searchUsers,
variables: {
searchText: this.criteria,
},
})
.then((result) => {
this.itemsList = result.data.searchUsers.map((user) => {
return {
...user,
showDetails: false,
}
})
})
.catch((error) => {
this.$toasted.error(error.message)
})
},
updateItem(e, event) {
let index = 0
let findArr = {}
switch (event) {
case 'push':
findArr = this.itemsList.find((arr) => arr.id === e.id)
index = this.itemsList.indexOf(findArr)
this.itemsList.splice(index, 1)
this.massCreation.push(e)
break
case 'remove':
findArr = this.massCreation.find((arr) => arr.id === e.id)
index = this.massCreation.indexOf(findArr)
this.massCreation.splice(index, 1)
this.itemsList.push(e)
break
default:
throw new Error(event)
}
},
updateRadioSelected(obj) {
this.radioSelectedMass = obj[0]
},
removeAllBookmark() {
alert('remove all bookmarks')
const index = 0
let i = 0
for (i; i < this.massCreation.length; i++) {
this.itemsList.push(this.massCreation[i])
}
this.massCreation.splice(index, this.massCreation.length)
},
},
}
</script>

View File

@ -0,0 +1,53 @@
import { mount } from '@vue/test-utils'
import CreationConfirm from './CreationConfirm.vue'
const localVue = global.localVue
const storeCommitMock = jest.fn()
const mocks = {
$store: {
commit: storeCommitMock,
},
}
describe('CreationConfirm', () => {
let wrapper
const Wrapper = () => {
return mount(CreationConfirm, { localVue, mocks })
}
describe('mount', () => {
beforeEach(() => {
jest.clearAllMocks()
wrapper = Wrapper()
})
it('has a DIV element with the class.creation-confirm', () => {
expect(wrapper.find('div.creation-confirm').exists()).toBeTruthy()
})
describe('store', () => {
it('commits resetOpenCreations to store', () => {
expect(storeCommitMock).toBeCalledWith('resetOpenCreations')
})
it('commits openCreationsPlus to store', () => {
expect(storeCommitMock).toBeCalledWith('openCreationsPlus', 5)
})
})
describe('confirm creation', () => {
beforeEach(async () => {
await wrapper
.findComponent({ name: 'UserTable' })
.vm.$emit('remove-confirm-result', 1, 'remove')
})
it('commits openCreationsMinus to store', () => {
expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1)
})
})
})
})

View File

@ -0,0 +1,149 @@
<template>
<div class="creation-confirm">
<small class="bg-danger text-light p-1">
Die anzahl der offene Schöpfungen stimmen nicht! Diese wird bei absenden im $store
hochgezählt. Die Liste die hier angezeigt wird ist SIMULIERT!
</small>
<user-table
class="mt-4"
type="PageCreationConfirm"
:itemsUser="confirmResult"
:fieldsTable="fields"
@remove-confirm-result="removeConfirmResult"
/>
</div>
</template>
<script>
import UserTable from '../components/UserTable.vue'
export default {
name: 'CreationConfirm',
components: {
UserTable,
},
data() {
return {
showArrays: false,
fields: [
{ key: 'bookmark', label: 'löschen' },
{ key: 'email', label: 'Email' },
{ key: 'firstName', label: 'Vorname' },
{ key: 'lastName', label: 'Nachname' },
{
key: 'creation_gdd',
label: 'Schöpfung',
formatter: (value) => {
return value + ' GDD'
},
},
{ key: 'text', label: 'Text' },
{
key: 'creation_date',
label: 'Datum',
formatter: (value) => {
return value.long
},
},
{ key: 'creation_moderator', label: 'Moderator' },
{ key: 'edit_creation', label: 'ändern' },
{ key: 'confirm', label: 'speichern' },
],
confirmResult: [
{
id: 1,
email: 'dickerson@web.de',
firstName: 'Dickerson',
lastName: 'Macdonald',
creation: '[450,200,700]',
creation_gdd: '1000',
text: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam ',
creation_date: {
short: 'November',
long: '22/11/2021',
},
creation_moderator: 'Manuela Gast',
},
{
id: 2,
email: 'larsen@woob.de',
firstName: 'Larsen',
lastName: 'Shaw',
creation: '[300,200,1000]',
creation_gdd: '1000',
text: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam ',
creation_date: {
short: 'November',
long: '03/11/2021',
},
creation_moderator: 'Manuela Gast',
},
{
id: 3,
email: 'geneva@tete.de',
firstName: 'Geneva',
lastName: 'Wilson',
creation: '[350,200,900]',
creation_gdd: '1000',
text: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam',
creation_date: {
short: 'September',
long: '27/09/2021',
},
creation_moderator: 'Manuela Gast',
},
{
id: 4,
email: 'viewrter@asdfvb.com',
firstName: 'Soledare',
lastName: 'Takker',
creation: '[100,400,800]',
creation_gdd: '500',
text: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo ',
creation_date: {
short: 'Oktober',
long: '12/10/2021',
},
creation_moderator: 'Evelyn Roller',
},
{
id: 5,
email: 'dickerson@web.de',
firstName: 'Dickerson',
lastName: 'Macdonald',
creation: '[100,400,800]',
creation_gdd: '200',
text: 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At',
creation_date: {
short: 'September',
long: '05/09/2021',
},
creation_moderator: 'Manuela Gast',
},
],
}
},
methods: {
removeConfirmResult(e, event) {
if (event === 'remove') {
let index = 0
let findArr = {}
findArr = this.confirmResult.find((arr) => arr.id === e.id)
index = this.confirmResult.indexOf(findArr)
this.confirmResult.splice(index, 1)
this.$store.commit('openCreationsMinus', 1)
}
},
},
created() {
this.$store.commit('resetOpenCreations')
this.$store.commit('openCreationsPlus', Object.keys(this.confirmResult).length)
},
}
</script>

View File

@ -0,0 +1,82 @@
<template>
<div>
<b-card
v-show="$store.state.openCreations > 0"
border-variant="primary"
header="offene Schöpfungen"
header-bg-variant="danger"
header-text-variant="white"
align="center"
>
<b-card-text>
<b-link to="creation-confirm">
<h1>{{ $store.state.openCreations }}</h1>
</b-link>
</b-card-text>
</b-card>
<b-card
v-show="$store.state.openCreations < 1"
border-variant="success"
header="keine offene Schöpfungen"
header-bg-variant="success"
header-text-variant="white"
align="center"
>
<b-card-text>
<b-link to="creation-confirm">
<h1>{{ $store.state.openCreations }}</h1>
</b-link>
</b-card-text>
</b-card>
<br />
<b-row>
<b-col>
<b-card border-variant="info" header="offene Registrierung" align="center">
<b-card-text>Unbestätigte E-mail Registrierung</b-card-text>
</b-card>
</b-col>
<b-col>
<b-card border-variant="info" header="geschöpfte Stunden" align="center">
<b-card-text>Wievile Stunden können noch von Mitgliedern geschöpft werden?</b-card-text>
</b-card>
</b-col>
<b-col>
<b-card border-variant="info" header="Gemeinschafts Konto" align="center">
<b-card-text>
Für jedes Mitglied kann für das Gemeinschaftskonto geschöpft werden. Pro Monat 1000 x
Mitglieder
</b-card-text>
</b-card>
</b-col>
</b-row>
<hr />
<br />
<b-list-group>
<b-list-group-item class="bg-secondary text-light" href="user">
zur Usersuche
</b-list-group-item>
<b-list-group-item class="d-flex justify-content-between align-items-center">
Mitglieder
<b-badge class="bg-success" pill>14</b-badge>
</b-list-group-item>
<b-list-group-item class="d-flex justify-content-between align-items-center">
aktive Mitglieder
<b-badge class="bg-primary" pill>12</b-badge>
</b-list-group-item>
<b-list-group-item class="d-flex justify-content-between align-items-center">
nicht bestätigte Mitglieder
<b-badge class="bg-warning text-dark" pill>2</b-badge>
</b-list-group-item>
</b-list-group>
<b-button @click="$store.commit('resetOpenCreations')">
lösche alle offenen Test Schöpfungen
</b-button>
</div>
</template>
<script>
export default {
name: 'overview',
}
</script>

View File

@ -0,0 +1,59 @@
import { mount } from '@vue/test-utils'
import UserSearch from './UserSearch.vue'
const localVue = global.localVue
const apolloQueryMock = jest.fn().mockResolvedValue({
data: {
searchUsers: [
{
firstName: 'Bibi',
lastName: 'Bloxberg',
email: 'bibi@bloxberg.de',
creation: [200, 400, 600],
},
],
},
})
const toastErrorMock = jest.fn()
const mocks = {
$apollo: {
query: apolloQueryMock,
},
$toasted: {
error: toastErrorMock,
},
}
describe('UserSearch', () => {
let wrapper
const Wrapper = () => {
return mount(UserSearch, { localVue, mocks })
}
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
})
it('has a DIV element with the class.user-search', () => {
expect(wrapper.find('div.user-search').exists()).toBeTruthy()
})
describe('apollo returns error', () => {
beforeEach(() => {
apolloQueryMock.mockRejectedValue({
message: 'Ouch',
})
wrapper = Wrapper()
})
it('toasts an error message', () => {
expect(toastErrorMock).toBeCalledWith('Ouch')
})
})
})
})

View File

@ -0,0 +1,70 @@
<template>
<div class="user-search">
<label>Usersuche</label>
<b-input
type="text"
v-model="criteria"
class="shadow p-3 mb-5 bg-white rounded"
placeholder="User suche"
@input="getUsers"
></b-input>
<user-table
type="PageUserSearch"
:itemsUser="searchResult"
:fieldsTable="fields"
:criteria="criteria"
/>
</div>
</template>
<script>
import UserTable from '../components/UserTable.vue'
import { searchUsers } from '../graphql/searchUsers'
export default {
name: 'UserSearch',
components: {
UserTable,
},
data() {
return {
showArrays: false,
fields: [
{ key: 'email', label: 'Email' },
{ key: 'firstName', label: 'Firstname' },
{ key: 'lastName', label: 'Lastname' },
{ key: 'creation', label: 'Creation' },
{ key: 'show_details', label: 'Details' },
],
searchResult: [],
massCreation: [],
criteria: '',
}
},
methods: {
getUsers() {
this.$apollo
.query({
query: searchUsers,
variables: {
searchText: this.criteria,
},
})
.then((result) => {
this.searchResult = result.data.searchUsers.map((user) => {
return {
...user,
// showDetails: true,
}
})
})
.catch((error) => {
this.$toasted.error(error.message)
})
},
},
created() {
this.getUsers()
},
}
</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

@ -0,0 +1,92 @@
import router from './router'
describe('router', () => {
describe('options', () => {
const { options } = router
const { scrollBehavior, routes } = options
it('has "/admin" as base', () => {
expect(options).toEqual(
expect.objectContaining({
base: '/admin',
}),
)
})
it('has "active" as linkActiveClass', () => {
expect(options).toEqual(
expect.objectContaining({
linkActiveClass: 'active',
}),
)
})
it('has "history" as mode', () => {
expect(options).toEqual(
expect.objectContaining({
mode: 'history',
}),
)
})
describe('scroll behavior', () => {
it('returns save position when given', () => {
expect(scrollBehavior({}, {}, 'given')).toBe('given')
})
it('returns selector when hash is given', () => {
expect(scrollBehavior({ hash: '#to' }, {})).toEqual({ selector: '#to' })
})
it('returns top left coordinates as default', () => {
expect(scrollBehavior({}, {})).toEqual({ x: 0, y: 0 })
})
})
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')
})
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')
})
})
describe('user', () => {
it('loads the "UserSearch" component', async () => {
const component = await routes.find((r) => r.path === '/user').component()
expect(component.default.name).toBe('UserSearch')
})
})
describe('creation', () => {
it('loads the "Creation" component', async () => {
const component = await routes.find((r) => r.path === '/creation').component()
expect(component.default.name).toBe('Creation')
})
})
describe('creation-confirm', () => {
it('loads the "CreationConfirm" component', async () => {
const component = await routes.find((r) => r.path === '/creation-confirm').component()
expect(component.default.name).toBe('CreationConfirm')
})
})
describe('not found page', () => {
it('renders the "NotFound" component', async () => {
const component = await routes.find((r) => r.path === '*').component()
expect(component.default.name).toEqual('not-found')
})
})
})
})
})

View File

@ -1,15 +1,32 @@
import NotFound from '@/components/NotFoundPage.vue'
const routes = [
{
path: '/',
/*
meta: {
requiresAuth: true,
},
*/
path: '/authenticate',
},
{
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('@/pages/UserSearch.vue'),
},
{
path: '/creation',
component: () => import('@/pages/Creation.vue'),
},
{
path: '/creation-confirm',
component: () => import('@/pages/CreationConfirm.vue'),
},
{
path: '*',
component: () => import('@/components/NotFoundPage.vue'),
},
{ path: '*', component: NotFound },
]
export default routes

View File

@ -1,19 +1,46 @@
import Vuex from 'vuex'
import Vue from 'vue'
import createPersistedState from 'vuex-persistedstate'
import CONFIG from '../config'
Vue.use(Vuex)
export const mutations = {
openCreationsPlus: (state, i) => {
state.openCreations += i
},
openCreationsMinus: (state, i) => {
state.openCreations -= i
},
resetOpenCreations: (state) => {
state.openCreations = 0
},
token: (state, token) => {
state.token = token
},
}
const store = new Vuex.Store({
mutations,
state: {
token: 'some-token',
export const actions = {
logout: ({ commit, state }) => {
commit('token', null)
window.localStorage.clear()
},
}
const store = new Vuex.Store({
plugins: [
createPersistedState({
storage: window.localStorage,
}),
],
state: {
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 } = mutations
const { token, openCreationsPlus, openCreationsMinus, resetOpenCreations } = mutations
const { logout } = actions
const CONFIG = {
DEBUG_DISABLE_AUTH: true,
}
describe('Vuex store', () => {
describe('mutations', () => {
@ -11,5 +16,68 @@ describe('Vuex store', () => {
expect(state.token).toEqual('1234')
})
})
describe('openCreationsPlus', () => {
it('increases the open creations by a given number', () => {
const state = { openCreations: 0 }
openCreationsPlus(state, 12)
expect(state.openCreations).toEqual(12)
})
})
describe('openCreationsMinus', () => {
it('decreases the open creations by a given number', () => {
const state = { openCreations: 12 }
openCreationsMinus(state, 2)
expect(state.openCreations).toEqual(10)
})
})
describe('resetOpenCreations', () => {
it('sets the open creations to 0', () => {
const state = { openCreations: 24 }
resetOpenCreations(state)
expect(state.openCreations).toEqual(0)
})
})
})
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

@ -1,6 +1,6 @@
import { createLocalVue } from '@vue/test-utils'
import Vue from 'vue'
import { BootstrapVue } from 'bootstrap-vue'
import { BootstrapVue, IconsPlugin } from 'bootstrap-vue'
// without this async calls are not working
import 'regenerator-runtime'
@ -8,6 +8,7 @@ import 'regenerator-runtime'
global.localVue = createLocalVue()
global.localVue.use(BootstrapVue)
global.localVue.use(IconsPlugin)
// throw errors for vue warnings to force the programmers to take care about warnings
Vue.config.warnHandler = (w) => {

View File

@ -6424,6 +6424,11 @@ har-validator@~5.1.3:
ajv "^6.12.3"
har-schema "^2.0.0"
harmony-reflect@^1.4.6:
version "1.6.2"
resolved "https://registry.yarnpkg.com/harmony-reflect/-/harmony-reflect-1.6.2.tgz#31ecbd32e648a34d030d86adb67d4d47547fe710"
integrity sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==
has-ansi@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
@ -6776,6 +6781,13 @@ icss-utils@^4.0.0, icss-utils@^4.1.1:
dependencies:
postcss "^7.0.14"
identity-obj-proxy@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz#94d2bda96084453ef36fbc5aaec37e0f79f1fc14"
integrity sha1-lNK9qWCERT7zb7xarsN+D3nx/BQ=
dependencies:
harmony-reflect "^1.4.6"
ieee754@^1.1.4:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
@ -9020,6 +9032,11 @@ mkdirp@0.x, mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.5, mkdirp@~0.5.1:
dependencies:
minimist "^1.2.5"
moment@^2.19.2, moment@^2.29.1:
version "2.29.1"
resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3"
integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==
move-concurrently@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/move-concurrently/-/move-concurrently-1.0.1.tgz#be2c005fda32e0b29af1f05d7c4b33214c701f92"
@ -11163,6 +11180,11 @@ shellwords@^0.1.1:
resolved "https://registry.yarnpkg.com/shellwords/-/shellwords-0.1.1.tgz#d6b9181c1a48d397324c84871efbcfc73fc0654b"
integrity sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==
shvl@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/shvl/-/shvl-2.0.3.tgz#eb4bd37644f5684bba1fc52c3010c96fb5e6afd1"
integrity sha512-V7C6S9Hlol6SzOJPnQ7qzOVEWUQImt3BNmmzh40wObhla3XOYMe4gGiYzLrJd5TFa+cI2f9LKIRJTTKZSTbWgw==
side-channel@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf"
@ -12469,6 +12491,13 @@ vue-loader@^15.9.2:
vue-hot-reload-api "^2.3.0"
vue-style-loader "^4.1.0"
vue-moment@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/vue-moment/-/vue-moment-4.1.0.tgz#092a8ff723a96c6f85a0a8e23ad30f0bf320f3b0"
integrity sha512-Gzisqpg82ItlrUyiD9d0Kfru+JorW2o4mQOH06lEDZNgxci0tv/fua1Hl0bo4DozDV2JK1r52Atn/8QVCu8qQw==
dependencies:
moment "^2.19.2"
vue-router@^3.5.3:
version "3.5.3"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.5.3.tgz#041048053e336829d05dafacf6a8fb669a2e7999"
@ -12500,6 +12529,14 @@ vue@^2.6.11:
resolved "https://registry.yarnpkg.com/vue/-/vue-2.6.14.tgz#e51aa5250250d569a3fbad3a8a5a687d6036e235"
integrity sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ==
vuex-persistedstate@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/vuex-persistedstate/-/vuex-persistedstate-4.1.0.tgz#127165f85f5b4534fb3170a5d3a8be9811bd2a53"
integrity sha512-3SkEj4NqwM69ikJdFVw6gObeB0NHyspRYMYkR/EbhR0hbvAKyR5gksVhtAfY1UYuWUOCCA0QNGwv9pOwdj+XUQ==
dependencies:
deepmerge "^4.2.2"
shvl "^2.0.3"
vuex@^3.6.2:
version "3.6.2"
resolved "https://registry.yarnpkg.com/vuex/-/vuex-3.6.2.tgz#236bc086a870c3ae79946f107f16de59d5895e71"

View File

@ -30,4 +30,6 @@ COMMUNITY_URL=
COMMUNITY_REGISTER_URL=
COMMUNITY_DESCRIPTION=
LOGIN_APP_SECRET=21ffbbc616fe
LOGIN_SERVER_KEY=a51ef8ac7ef1abf162fb7a65261acd7a
LOGIN_SERVER_KEY=a51ef8ac7ef1abf162fb7a65261acd7a
WEBHOOK_ELOPAGE_SECRET=secret

View File

@ -20,6 +20,7 @@
"apollo-server-express": "^2.25.2",
"apollo-server-testing": "^2.25.2",
"axios": "^0.21.1",
"body-parser": "^1.19.0",
"class-validator": "^0.13.1",
"cors": "^2.8.5",
"dotenv": "^10.0.0",

View File

@ -0,0 +1,5 @@
import { JwtPayload } from 'jsonwebtoken'
export interface CustomJwtPayload extends JwtPayload {
pubKey: Buffer
}

View File

@ -0,0 +1,13 @@
import { RIGHTS } from './RIGHTS'
export const INALIENABLE_RIGHTS = [
RIGHTS.LOGIN,
RIGHTS.GET_COMMUNITY_INFO,
RIGHTS.COMMUNITIES,
RIGHTS.LOGIN_VIA_EMAIL_VERIFICATION_CODE,
RIGHTS.CREATE_USER,
RIGHTS.SEND_RESET_PASSWORD_EMAIL,
RIGHTS.RESET_PASSWORD,
RIGHTS.CHECK_USERNAME,
RIGHTS.CHECK_EMAIL,
]

19
backend/src/auth/JWT.ts Normal file
View File

@ -0,0 +1,19 @@
import jwt from 'jsonwebtoken'
import CONFIG from '../config/'
import { CustomJwtPayload } from './CustomJwtPayload'
export const decode = (token: string): CustomJwtPayload | null => {
if (!token) throw new Error('401 Unauthorized')
try {
return <CustomJwtPayload>jwt.verify(token, CONFIG.JWT_SECRET)
} catch (err) {
return null
}
}
export const encode = (pubKey: Buffer): string => {
const token = jwt.sign({ pubKey }, CONFIG.JWT_SECRET, {
expiresIn: CONFIG.JWT_EXPIRES_IN,
})
return token
}

View File

@ -0,0 +1,26 @@
export enum RIGHTS {
LOGIN = 'LOGIN',
VERIFY_LOGIN = 'VERIFY_LOGIN',
BALANCE = 'BALANCE',
GET_COMMUNITY_INFO = 'GET_COMMUNITY_INFO',
COMMUNITIES = 'COMMUNITIES',
LIST_GDT_ENTRIES = 'LIST_GDT_ENTRIES',
EXIST_PID = 'EXIST_PID',
GET_KLICKTIPP_USER = 'GET_KLICKTIPP_USER',
GET_KLICKTIPP_TAG_MAP = 'GET_KLICKTIPP_TAG_MAP',
UNSUBSCRIBE_NEWSLETTER = 'UNSUBSCRIBE_NEWSLETTER',
SUBSCRIBE_NEWSLETTER = 'SUBSCRIBE_NEWSLETTER',
TRANSACTION_LIST = 'TRANSACTION_LIST',
SEND_COINS = 'SEND_COINS',
LOGIN_VIA_EMAIL_VERIFICATION_CODE = 'LOGIN_VIA_EMAIL_VERIFICATION_CODE',
LOGOUT = 'LOGOUT',
CREATE_USER = 'CREATE_USER',
SEND_RESET_PASSWORD_EMAIL = 'SEND_RESET_PASSWORD_EMAIL',
RESET_PASSWORD = 'RESET_PASSWORD',
UPDATE_USER_INFOS = 'UPDATE_USER_INFOS',
CHECK_USERNAME = 'CHECK_USERNAME',
CHECK_EMAIL = 'CHECK_EMAIL',
HAS_ELOPAGE = 'HAS_ELOPAGE',
// Admin
SEARCH_USERS = 'SEARCH_USERS',
}

25
backend/src/auth/ROLES.ts Normal file
View File

@ -0,0 +1,25 @@
import { INALIENABLE_RIGHTS } from './INALIENABLE_RIGHTS'
import { RIGHTS } from './RIGHTS'
import { Role } from './Role'
export const ROLE_UNAUTHORIZED = new Role('unauthorized', INALIENABLE_RIGHTS)
export const ROLE_USER = new Role('user', [
...INALIENABLE_RIGHTS,
RIGHTS.VERIFY_LOGIN,
RIGHTS.BALANCE,
RIGHTS.LIST_GDT_ENTRIES,
RIGHTS.EXIST_PID,
RIGHTS.GET_KLICKTIPP_USER,
RIGHTS.GET_KLICKTIPP_TAG_MAP,
RIGHTS.UNSUBSCRIBE_NEWSLETTER,
RIGHTS.SUBSCRIBE_NEWSLETTER,
RIGHTS.TRANSACTION_LIST,
RIGHTS.SEND_COINS,
RIGHTS.LOGOUT,
RIGHTS.UPDATE_USER_INFOS,
RIGHTS.HAS_ELOPAGE,
])
export const ROLE_ADMIN = new Role('admin', Object.values(RIGHTS)) // all rights
// TODO from database
export const ROLES = [ROLE_UNAUTHORIZED, ROLE_USER, ROLE_ADMIN]

15
backend/src/auth/Role.ts Normal file
View File

@ -0,0 +1,15 @@
import { RIGHTS } from './RIGHTS'
export class Role {
id: string
rights: RIGHTS[]
constructor(id: string, rights: RIGHTS[]) {
this.id = id
this.rights = rights
}
hasRight = (right: RIGHTS): boolean => {
return this.rights.includes(right)
}
}

View File

@ -51,14 +51,25 @@ const email = {
EMAIL_PASSWORD: process.env.EMAIL_PASSWORD || 'xxx',
EMAIL_SMTP_URL: process.env.EMAIL_SMTP_URL || 'gmail.com',
EMAIL_SMTP_PORT: process.env.EMAIL_SMTP_PORT || '587',
EMAIL_LINK_VERIFICATION:
process.env.EMAIL_LINK_VERIFICATION || 'http://localhost/vue/checkEmail/$1',
}
const webhook = {
WEBHOOK_ELOPAGE_SECRET: process.env.WEBHOOK_ELOPAGE_SECRET || 'secret',
}
// This is needed by graphql-directive-auth
process.env.APP_SECRET = server.JWT_SECRET
const CONFIG = { ...server, ...database, ...klicktipp, ...community, ...email, ...loginServer }
const CONFIG = {
...server,
...database,
...klicktipp,
...community,
...email,
...loginServer,
...webhook,
}
export default CONFIG

View File

@ -15,7 +15,7 @@ export default class CreateUserArgs {
password: string
@Field(() => String)
language: string
language?: string // Will default to DEFAULT_LANGUAGE
@Field(() => Int, { nullable: true })
publisherId: number

View File

@ -2,19 +2,44 @@
import { AuthChecker } from 'type-graphql'
import decode from '../../jwt/decode'
import encode from '../../jwt/encode'
import { decode, encode } from '../../auth/JWT'
import { ROLE_UNAUTHORIZED, ROLE_USER, ROLE_ADMIN } from '../../auth/ROLES'
import { RIGHTS } from '../../auth/RIGHTS'
import { ServerUserRepository } from '../../typeorm/repository/ServerUser'
import { getCustomRepository } from 'typeorm'
import { UserRepository } from '../../typeorm/repository/User'
const isAuthorized: AuthChecker<any> = async (
{ /* root, args, */ context /*, info */ } /*, roles */,
) => {
const isAuthorized: AuthChecker<any> = async ({ context }, rights) => {
context.role = ROLE_UNAUTHORIZED // unauthorized user
// Do we have a token?
if (context.token) {
const decoded = decode(context.token)
context.pubKey = decoded.pubKey
if (!decoded) {
// we always throw on an invalid token
throw new Error('403.13 - Client certificate revoked')
}
// Set context pubKey
context.pubKey = Buffer.from(decoded.pubKey).toString('hex')
// set new header token
// TODO - load from database dynamically & admin - maybe encode this in the token to prevent many database requests
// TODO this implementation is bullshit - two database queries cause our user identifiers are not aligned and vary between email, id and pubKey
const userRepository = await getCustomRepository(UserRepository)
const user = await userRepository.findByPubkeyHex(context.pubKey)
const serverUserRepository = await getCustomRepository(ServerUserRepository)
const countServerUsers = await serverUserRepository.count({ email: user.email })
context.role = countServerUsers > 0 ? ROLE_ADMIN : ROLE_USER
context.setHeaders.push({ key: 'token', value: encode(decoded.pubKey) })
return true
}
throw new Error('401 Unauthorized')
// check for correct rights
const missingRights = (<RIGHTS[]>rights).filter((right) => !context.role.hasRight(right))
if (missingRights.length !== 0) {
throw new Error('401 Unauthorized')
}
return true
}
export default isAuthorized

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

@ -0,0 +1,16 @@
import { ObjectType, Field } from 'type-graphql'
@ObjectType()
export class UserAdmin {
@Field(() => String)
email: string
@Field(() => String)
firstName: string
@Field(() => String)
lastName: string
@Field(() => [Number])
creation: number[]
}

View File

@ -0,0 +1,28 @@
import { Resolver, Query, Arg, Authorized } from 'type-graphql'
import { getCustomRepository } from 'typeorm'
import { UserAdmin } from '../model/UserAdmin'
import { LoginUserRepository } from '../../typeorm/repository/LoginUser'
import { RIGHTS } from '../../auth/RIGHTS'
@Resolver()
export class AdminResolver {
@Authorized([RIGHTS.SEARCH_USERS])
@Query(() => [UserAdmin])
async searchUsers(@Arg('searchText') searchText: string): Promise<UserAdmin[]> {
const loginUserRepository = getCustomRepository(LoginUserRepository)
const loginUsers = await loginUserRepository.findBySearchCriteria(searchText)
const users = loginUsers.map((loginUser) => {
const user = new UserAdmin()
user.firstName = loginUser.firstName
user.lastName = loginUser.lastName
user.email = loginUser.email
user.creation = [
(Math.floor(Math.random() * 50) + 1) * 20,
(Math.floor(Math.random() * 50) + 1) * 20,
(Math.floor(Math.random() * 50) + 1) * 20,
]
return user
})
return users
}
}

View File

@ -8,10 +8,11 @@ import { BalanceRepository } from '../../typeorm/repository/Balance'
import { UserRepository } from '../../typeorm/repository/User'
import { calculateDecay } from '../../util/decay'
import { roundFloorFrom4 } from '../../util/round'
import { RIGHTS } from '../../auth/RIGHTS'
@Resolver()
export class BalanceResolver {
@Authorized()
@Authorized([RIGHTS.BALANCE])
@Query(() => Balance)
async balance(@Ctx() context: any): Promise<Balance> {
// load user and balance

View File

@ -1,12 +1,14 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import { Resolver, Query } from 'type-graphql'
import { Resolver, Query, Authorized } from 'type-graphql'
import { RIGHTS } from '../../auth/RIGHTS'
import CONFIG from '../../config'
import { Community } from '../model/Community'
@Resolver()
export class CommunityResolver {
@Authorized([RIGHTS.GET_COMMUNITY_INFO])
@Query(() => Community)
async getCommunityInfo(): Promise<Community> {
return new Community({
@ -17,6 +19,7 @@ export class CommunityResolver {
})
}
@Authorized([RIGHTS.COMMUNITIES])
@Query(() => [Community])
async communities(): Promise<Community[]> {
if (CONFIG.PRODUCTION)

View File

@ -9,10 +9,11 @@ import Paginated from '../arg/Paginated'
import { apiGet } from '../../apis/HttpRequest'
import { UserRepository } from '../../typeorm/repository/User'
import { Order } from '../enum/Order'
import { RIGHTS } from '../../auth/RIGHTS'
@Resolver()
export class GdtResolver {
@Authorized()
@Authorized([RIGHTS.LIST_GDT_ENTRIES])
@Query(() => GdtEntryList)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async listGDTEntries(
@ -33,7 +34,7 @@ export class GdtResolver {
return new GdtEntryList(resultGDT.data)
}
@Authorized()
@Authorized([RIGHTS.EXIST_PID])
@Query(() => Number)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async existPid(@Arg('pid') pid: number): Promise<number> {

View File

@ -8,29 +8,30 @@ import {
unsubscribe,
signIn,
} from '../../apis/KlicktippController'
import { RIGHTS } from '../../auth/RIGHTS'
import SubscribeNewsletterArgs from '../arg/SubscribeNewsletterArgs'
@Resolver()
export class KlicktippResolver {
@Authorized()
@Authorized([RIGHTS.GET_KLICKTIPP_USER])
@Query(() => String)
async getKlicktippUser(@Arg('email') email: string): Promise<string> {
return await getKlickTippUser(email)
}
@Authorized()
@Authorized([RIGHTS.GET_KLICKTIPP_TAG_MAP])
@Query(() => String)
async getKlicktippTagMap(): Promise<string> {
return await getKlicktippTagMap()
}
@Authorized()
@Authorized([RIGHTS.UNSUBSCRIBE_NEWSLETTER])
@Mutation(() => Boolean)
async unsubscribeNewsletter(@Arg('email') email: string): Promise<boolean> {
return await unsubscribe(email)
}
@Authorized()
@Authorized([RIGHTS.SUBSCRIBE_NEWSLETTER])
@Mutation(() => Boolean)
async subscribeNewsletter(
@Args() { email, language }: SubscribeNewsletterArgs,

View File

@ -34,6 +34,7 @@ import { TransactionTypeId } from '../enum/TransactionTypeId'
import { TransactionType } from '../enum/TransactionType'
import { hasUserAmount, isHexPublicKey } from '../../util/validate'
import { LoginUserRepository } from '../../typeorm/repository/LoginUser'
import { RIGHTS } from '../../auth/RIGHTS'
/*
# Test
@ -465,7 +466,7 @@ async function getPublicKey(email: string): Promise<string | null> {
@Resolver()
export class TransactionResolver {
@Authorized()
@Authorized([RIGHTS.TRANSACTION_LIST])
@Query(() => TransactionList)
async transactionList(
@Args() { currentPage = 1, pageSize = 25, order = Order.DESC }: Paginated,
@ -499,7 +500,7 @@ export class TransactionResolver {
return transactions
}
@Authorized()
@Authorized([RIGHTS.SEND_COINS])
@Mutation(() => String)
async sendCoins(
@Args() { email, amount, memo }: TransactionSendArgs,
@ -613,9 +614,6 @@ export class TransactionResolver {
await queryRunner.commitTransaction()
} catch (e) {
await queryRunner.rollbackTransaction()
throw e
} finally {
await queryRunner.release()
// TODO: This is broken code - we should never correct an autoincrement index in production
// according to dario it is required tho to properly work. The index of the table is used as
// index for the transaction which requires a chain without gaps
@ -627,6 +625,9 @@ export class TransactionResolver {
// eslint-disable-next-line no-console
console.log('problems with reset auto increment: %o', error)
})
throw e
} finally {
await queryRunner.release()
}
// send notification email
// TODO: translate

View File

@ -9,7 +9,7 @@ import { LoginViaVerificationCode } from '../model/LoginViaVerificationCode'
import { SendPasswordResetEmailResponse } from '../model/SendPasswordResetEmailResponse'
import { User } from '../model/User'
import { User as DbUser } from '@entity/User'
import encode from '../../jwt/encode'
import { encode } from '../../auth/JWT'
import ChangePasswordArgs from '../arg/ChangePasswordArgs'
import CheckUsernameArgs from '../arg/CheckUsernameArgs'
import CreateUserArgs from '../arg/CreateUserArgs'
@ -22,14 +22,17 @@ import {
} from '../../middleware/klicktippMiddleware'
import { CheckEmailResponse } from '../model/CheckEmailResponse'
import { UserSettingRepository } from '../../typeorm/repository/UserSettingRepository'
import { LoginUserRepository } from '../../typeorm/repository/LoginUser'
import { Setting } from '../enum/Setting'
import { UserRepository } from '../../typeorm/repository/User'
import { LoginUser } from '@entity/LoginUser'
import { LoginElopageBuys } from '@entity/LoginElopageBuys'
import { LoginUserBackup } from '@entity/LoginUserBackup'
import { LoginEmailOptIn } from '@entity/LoginEmailOptIn'
import { sendEMail } from '../../util/sendEMail'
import { LoginUserRepository } from '../../typeorm/repository/LoginUser'
import { LoginElopageBuysRepository } from '../../typeorm/repository/LoginElopageBuys'
import { RIGHTS } from '../../auth/RIGHTS'
import { ServerUserRepository } from '../../typeorm/repository/ServerUser'
import { ROLE_ADMIN } from '../../auth/ROLES'
// eslint-disable-next-line @typescript-eslint/no-var-requires
const sodium = require('sodium-native')
@ -194,6 +197,41 @@ const SecretKeyCryptographyDecrypt = (encryptedMessage: Buffer, encryptionKey: B
@Resolver()
export class UserResolver {
@Authorized([RIGHTS.VERIFY_LOGIN])
@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 = context.role === ROLE_ADMIN
return user
}
@Authorized([RIGHTS.LOGIN])
@Query(() => User)
@UseMiddleware(klicktippNewsletterStateMiddleware)
async login(
@ -201,33 +239,33 @@ export class UserResolver {
@Ctx() context: any,
): Promise<User> {
email = email.trim().toLowerCase()
const result = await apiPost(CONFIG.LOGIN_API_URL + 'unsecureLogin', { email, password })
// if there is no user, throw an authentication error
if (!result.success) {
throw new Error(result.data)
}
context.setHeaders.push({
key: 'token',
value: encode(result.data.user.public_hex),
// const result = await apiPost(CONFIG.LOGIN_API_URL + 'unsecureLogin', { email, password })
// UnsecureLogin
const loginUserRepository = getCustomRepository(LoginUserRepository)
const loginUser = await loginUserRepository.findByEmail(email).catch(() => {
throw new Error('No user with this credentials')
})
const user = new User(result.data.user)
// Hack: Database Field is not validated properly and not nullable
if (user.publisherId === 0) {
user.publisherId = undefined
if (!loginUser.emailChecked) throw new Error('user email not validated')
const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash
const loginUserPassword = BigInt(loginUser.password.toString())
if (loginUserPassword !== passwordHash[0].readBigUInt64LE()) {
throw new Error('No user with this credentials')
}
user.hasElopage = result.data.hasElopage
// read additional settings from settings table
// TODO: If user has no pubKey Create it again and update user.
const userRepository = getCustomRepository(UserRepository)
let userEntity: void | DbUser
userEntity = await userRepository.findByPubkeyHex(user.pubkey).catch(() => {
const loginUserPubKey = loginUser.pubKey
const loginUserPubKeyString = loginUserPubKey.toString('hex')
userEntity = await userRepository.findByPubkeyHex(loginUserPubKeyString).catch(() => {
// User not stored in state_users
// TODO: Check with production data - email is unique which can cause problems
userEntity = new DbUser()
userEntity.firstName = user.firstName
userEntity.lastName = user.lastName
userEntity.username = user.username
userEntity.email = user.email
userEntity.pubkey = Buffer.from(user.pubkey, 'hex')
userEntity.firstName = loginUser.firstName
userEntity.lastName = loginUser.lastName
userEntity.username = loginUser.username
userEntity.email = loginUser.email
userEntity.pubkey = loginUser.pubKey
userRepository.save(userEntity).catch(() => {
throw new Error('error by save userEntity')
@ -237,16 +275,28 @@ export class UserResolver {
throw new Error('error with cannot happen')
}
// Save publisherId if Elopage is not yet registered
const user = new User()
user.email = email
user.firstName = loginUser.firstName
user.lastName = loginUser.lastName
user.username = loginUser.username
user.description = loginUser.description
user.pubkey = loginUserPubKeyString
user.language = loginUser.language
// Elopage Status & Stored PublisherId
user.hasElopage = await this.hasElopage({ pubKey: loginUserPubKeyString })
if (!user.hasElopage && publisherId) {
user.publisherId = publisherId
// TODO: Check if we can use updateUserInfos
// await this.updateUserInfos({ publisherId }, { pubKey: loginUser.pubKey })
const loginUserRepository = getCustomRepository(LoginUserRepository)
const loginUser = await loginUserRepository.findOneOrFail({ email: userEntity.email })
loginUser.publisherId = publisherId
loginUserRepository.save(loginUser)
}
// coinAnimation
const userSettingRepository = getCustomRepository(UserSettingRepository)
const coinanimation = await userSettingRepository
.readBoolean(userEntity.id, Setting.COIN_ANIMATION)
@ -254,9 +304,21 @@ export class UserResolver {
throw new Error(error)
})
user.coinanimation = coinanimation
// context.role is not set to the actual role yet on login
const serverUserRepository = await getCustomRepository(ServerUserRepository)
const countServerUsers = await serverUserRepository.count({ email: user.email })
user.isAdmin = countServerUsers > 0
context.setHeaders.push({
key: 'token',
value: encode(loginUser.pubKey),
})
return user
}
@Authorized([RIGHTS.LOGIN_VIA_EMAIL_VERIFICATION_CODE])
@Query(() => LoginViaVerificationCode)
async loginViaEmailVerificationCode(
@Arg('optin') optin: string,
@ -272,7 +334,7 @@ export class UserResolver {
return new LoginViaVerificationCode(result.data)
}
@Authorized()
@Authorized([RIGHTS.LOGOUT])
@Query(() => String)
async logout(): Promise<boolean> {
// TODO: We dont need this anymore, but might need this in the future in oder to invalidate a valid JWT-Token.
@ -283,6 +345,7 @@ export class UserResolver {
return true
}
@Authorized([RIGHTS.CREATE_USER])
@Mutation(() => String)
async createUser(
@Args() { email, firstName, lastName, password, language, publisherId }: CreateUserArgs,
@ -291,7 +354,7 @@ export class UserResolver {
// default int publisher_id = 0;
// Validate Language (no throw)
if (!isLanguage(language)) {
if (!language || !isLanguage(language)) {
language = DEFAULT_LANGUAGE
}
@ -423,6 +486,7 @@ export class UserResolver {
return 'success'
}
@Authorized([RIGHTS.SEND_RESET_PASSWORD_EMAIL])
@Query(() => SendPasswordResetEmailResponse)
async sendResetPasswordEmail(
@Arg('email') email: string,
@ -439,6 +503,7 @@ export class UserResolver {
return new SendPasswordResetEmailResponse(response.data)
}
@Authorized([RIGHTS.RESET_PASSWORD])
@Mutation(() => String)
async resetPassword(
@Args()
@ -456,7 +521,7 @@ export class UserResolver {
return 'success'
}
@Authorized()
@Authorized([RIGHTS.UPDATE_USER_INFOS])
@Mutation(() => Boolean)
async updateUserInfos(
@Args()
@ -537,7 +602,7 @@ export class UserResolver {
await queryRunner.startTransaction('READ UNCOMMITTED')
try {
if (coinanimation) {
if (coinanimation !== null && coinanimation !== undefined) {
queryRunner.manager
.getCustomRepository(UserSettingRepository)
.setOrUpdate(userEntity.id, Setting.COIN_ANIMATION, coinanimation.toString())
@ -565,6 +630,7 @@ export class UserResolver {
return true
}
@Authorized([RIGHTS.CHECK_USERNAME])
@Query(() => Boolean)
async checkUsername(@Args() { username }: CheckUsernameArgs): Promise<boolean> {
// Username empty?
@ -588,6 +654,7 @@ export class UserResolver {
return true
}
@Authorized([RIGHTS.CHECK_EMAIL])
@Query(() => CheckEmailResponse)
@UseMiddleware(klicktippRegistrationMiddleware)
async checkEmail(@Arg('optin') optin: string): Promise<CheckEmailResponse> {
@ -600,7 +667,7 @@ export class UserResolver {
return new CheckEmailResponse(result.data)
}
@Authorized()
@Authorized([RIGHTS.HAS_ELOPAGE])
@Query(() => Boolean)
async hasElopage(@Ctx() context: any): Promise<boolean> {
const userRepository = getCustomRepository(UserRepository)
@ -609,7 +676,8 @@ export class UserResolver {
return false
}
const elopageBuyCount = await LoginElopageBuys.count({ payerEmail: userEntity.email })
const loginElopageBuysRepository = getCustomRepository(LoginElopageBuysRepository)
const elopageBuyCount = await loginElopageBuysRepository.count({ payerEmail: userEntity.email })
return elopageBuyCount > 0
}
}

View File

@ -1,26 +0,0 @@
import jwt, { JwtPayload } from 'jsonwebtoken'
import CONFIG from '../config/'
interface CustomJwtPayload extends JwtPayload {
pubKey: Buffer
}
type DecodedJwt = {
token: string
pubKey: Buffer
}
export default (token: string): DecodedJwt => {
if (!token) throw new Error('401 Unauthorized')
let pubKey = null
try {
const decoded = <CustomJwtPayload>jwt.verify(token, CONFIG.JWT_SECRET)
pubKey = decoded.pubKey
return {
token,
pubKey,
}
} catch (err) {
throw new Error('403.13 - Client certificate revoked')
}
}

View File

@ -1,13 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
import jwt from 'jsonwebtoken'
import CONFIG from '../config/'
// Generate an Access Token
export default function encode(pubKey: Buffer): string {
const token = jwt.sign({ pubKey }, CONFIG.JWT_SECRET, {
expiresIn: CONFIG.JWT_EXPIRES_IN,
})
return token
}

View File

@ -6,6 +6,7 @@ import 'module-alias/register'
import { ApolloServer } from 'apollo-server-express'
import express from 'express'
import bodyParser from 'body-parser'
// database
import connection from '../typeorm/connection'
@ -22,6 +23,9 @@ import CONFIG from '../config'
// graphql
import schema from '../graphql/schema'
// webhooks
import { elopageWebhook } from '../webhook/elopage'
// TODO implement
// import queryComplexity, { simpleEstimator, fieldConfigEstimator } from "graphql-query-complexity";
@ -50,6 +54,12 @@ const createServer = async (context: any = serverContext): Promise<any> => {
// cors
app.use(cors)
// bodyparser
app.use(bodyParser.json())
// Elopage Webhook
app.post('/hook/elopage/' + CONFIG.WEBHOOK_ELOPAGE_SECRET, elopageWebhook)
// Apollo Server
const apollo = new ApolloServer({
schema: await schema(),

View File

@ -0,0 +1,5 @@
import { EntityRepository, Repository } from 'typeorm'
import { LoginElopageBuys } from '@entity/LoginElopageBuys'
@EntityRepository(LoginElopageBuys)
export class LoginElopageBuysRepository extends Repository<LoginElopageBuys> {}

View File

@ -2,4 +2,23 @@ import { EntityRepository, Repository } from 'typeorm'
import { LoginUser } from '@entity/LoginUser'
@EntityRepository(LoginUser)
export class LoginUserRepository extends Repository<LoginUser> {}
export class LoginUserRepository extends Repository<LoginUser> {
async findByEmail(email: string): Promise<LoginUser> {
return this.createQueryBuilder('loginUser')
.where('loginUser.email = :email', { email })
.getOneOrFail()
}
async findBySearchCriteria(searchCriteria: string): Promise<LoginUser[]> {
return await this.createQueryBuilder('user')
.where(
'user.firstName like :name or user.lastName like :lastName or user.email like :email',
{
name: `%${searchCriteria}%`,
lastName: `%${searchCriteria}%`,
email: `%${searchCriteria}%`,
},
)
.getMany()
}
}

View File

@ -0,0 +1,5 @@
import { EntityRepository, Repository } from 'typeorm'
import { ServerUser } from '@entity/ServerUser'
@EntityRepository(ServerUser)
export class ServerUserRepository extends Repository<ServerUser> {}

View File

@ -9,6 +9,15 @@ export class UserRepository extends Repository<User> {
.getOneOrFail()
}
async findByPubkeyHexBuffer(pubkeyHexBuffer: Buffer): Promise<User> {
const pubKeyString = pubkeyHexBuffer.toString('hex')
return await this.findByPubkeyHex(pubKeyString)
}
async findByEmail(email: string): Promise<User> {
return this.createQueryBuilder('user').where('user.email = :email', { email }).getOneOrFail()
}
async getUsersIndiced(userIds: number[]): Promise<User[]> {
if (!userIds.length) return []
const users = await this.createQueryBuilder('user')

File diff suppressed because one or more lines are too long

View File

@ -1552,7 +1552,7 @@ binary-extensions@^2.0.0:
resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
body-parser@1.19.0, body-parser@^1.18.3:
body-parser@1.19.0, body-parser@^1.18.3, body-parser@^1.19.0:
version "1.19.0"
resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a"
integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw==

View File

@ -0,0 +1,31 @@
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
@Entity('server_users')
export class ServerUser extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ length: 50 })
username: string
@Column({ type: 'bigint', unsigned: true })
password: BigInt
@Column({ length: 50, unique: true })
email: string
@Column({ length: 20, default: 'admin' })
role: string
@Column({ default: 0 })
activated: number
@Column({ name: 'last_login', default: null, nullable: true })
lastLogin: Date
@Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP' })
created: Date
@Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP' })
modified: Date
}

View File

@ -1,31 +1 @@
import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm'
@Entity('server_users')
export class ServerUser extends BaseEntity {
@PrimaryGeneratedColumn('increment', { unsigned: true })
id: number
@Column({ length: 50 })
username: string
@Column({ type: 'bigint', unsigned: true })
password: BigInt
@Column({ length: 50, unique: true })
email: string
@Column({ length: 20, default: 'admin' })
role: string
@Column({ default: 0 })
activated: number
@Column({ name: 'last_login', default: null, nullable: true })
lastLogin: Date
@Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP' })
created: Date
@Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP' })
modified: Date
}
export { ServerUser } from './0001-init_db/ServerUser'

View File

@ -4,6 +4,7 @@ import { LoginEmailOptIn } from './LoginEmailOptIn'
import { LoginUser } from './LoginUser'
import { LoginUserBackup } from './LoginUserBackup'
import { Migration } from './Migration'
import { ServerUser } from './ServerUser'
import { Transaction } from './Transaction'
import { TransactionCreation } from './TransactionCreation'
import { TransactionSendCoin } from './TransactionSendCoin'
@ -18,6 +19,7 @@ export const entities = [
LoginUser,
LoginUserBackup,
Migration,
ServerUser,
Transaction,
TransactionCreation,
TransactionSendCoin,

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",
@ -46,6 +47,7 @@
"change-password": "Fehler beim Ändern des Passworts",
"error": "Fehler",
"no-account": "Leider konnten wir keinen Account finden mit diesen Daten!",
"no-email-verify": "Die Email wurde noch nicht bestätigt, bitte überprüfe deine Emails und klicke auf den Aktivierungslink!",
"session-expired": "Sitzung abgelaufen!"
},
"form": {
@ -180,9 +182,12 @@
"uppercase": "Ein Großbuchstabe erforderlich."
},
"thx": {
"activateEmail": "Deine Email wurde noch nicht aktiviert, bitte überprüfe deine Email und Klicke den Aktivierungslink!",
"checkEmail": "Deine Email würde erfolgreich verifiziert.",
"email": "Wir haben dir eine eMail gesendet.",
"register": "Du bist jetzt registriert.",
"emailActivated": "Danke dass Du deine Email bestätigt hast.",
"errorTitle": "Achtung!",
"register": "Du bist jetzt registriert, bitte überprüfe deine Emails und klicke auf den Aktivierungslink.",
"reset": "Dein Passwort wurde geändert.",
"title": "Danke!"
}

View File

@ -1,4 +1,5 @@
{
"admin_area": "Admin's area",
"back": "Back",
"community": {
"choose-another-community": "Choose another community",
@ -46,6 +47,7 @@
"change-password": "Error while changing password",
"error": "Error",
"no-account": "Unfortunately we could not find an account to the given data!",
"no-email-verify": "Your email is not activated yet, please check your emails and click the activation link!",
"session-expired": "The session expired"
},
"form": {
@ -180,9 +182,12 @@
"uppercase": "One uppercase letter required."
},
"thx": {
"activateEmail": "Your email has not been activated yet, please check your emails and click the activation link!",
"checkEmail": "Your email has been successfully verified.",
"email": "We have sent you an email.",
"register": "You are registred now.",
"emailActivated": "Thank you your email has been activated.",
"errorTitle": "Attention!",
"register": "You are registered now, please check your emails and click the activation link.",
"reset": "Your password has been changed.",
"title": "Thank you!"
}

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,33 @@
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) {
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) => {
@ -47,7 +50,7 @@ const routes = [
path: '/thx/:comingFrom',
component: () => import('../views/Pages/thx.vue'),
beforeEnter: (to, from, next) => {
const validFrom = ['password', 'reset', 'register']
const validFrom = ['password', 'reset', 'register', 'login']
if (!validFrom.includes(from.path.split('/')[1])) {
next({ path: '/login' })
} else {

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

View File

@ -104,9 +104,14 @@ export default {
this.$router.push('/overview')
loader.hide()
})
.catch(() => {
.catch((error) => {
if (!error.message.includes('user email not validated')) {
this.$toasted.error(this.$t('error.no-account'))
} else {
// : this.$t('error.no-email-verify')
this.$router.push('/thx/login')
}
loader.hide()
this.$toasted.error(this.$t('error.no-account'))
})
},
},

View File

@ -161,6 +161,7 @@ import InputEmail from '../../components/Inputs/InputEmail.vue'
import InputPasswordConfirmation from '../../components/Inputs/InputPasswordConfirmation.vue'
import LanguageSwitchSelect from '../../components/LanguageSwitchSelect.vue'
import { registerUser } from '../../graphql/mutations'
import { localeChanged } from 'vee-validate'
import { getCommunityInfoMixin } from '../../mixins/getCommunityInfo'
export default {
@ -189,6 +190,9 @@ export default {
methods: {
updateLanguage(e) {
this.language = e
this.$store.commit('language', this.language)
this.$i18n.locale = this.language
localeChanged(this.language)
},
getValidationState({ dirty, validated, valid = null }) {
return dirty || validated ? valid : null

View File

@ -4,10 +4,12 @@
<div class="header py-7 py-lg-8 pt-lg-9">
<b-container>
<div class="header-body text-center mb-7">
<p class="h1">{{ $t('site.thx.title') }}</p>
<p class="h1">{{ $t(displaySetup.headline) }}</p>
<p class="h4">{{ $t(displaySetup.subtitle) }}</p>
<hr />
<b-button :to="displaySetup.linkTo">{{ $t(displaySetup.button) }}</b-button>
<b-button v-if="displaySetup.linkTo" :to="displaySetup.linkTo">
{{ $t(displaySetup.button) }}
</b-button>
</div>
</b-container>
</div>
@ -17,25 +19,33 @@
<script>
const textFields = {
password: {
headline: 'site.thx.title',
subtitle: 'site.thx.email',
button: 'login',
linkTo: '/login',
},
reset: {
headline: 'site.thx.title',
subtitle: 'site.thx.reset',
button: 'login',
linkTo: '/login',
},
register: {
headline: 'site.thx.title',
subtitle: 'site.thx.register',
button: 'site.login.signin',
linkTo: '/overview',
},
checkEmail: {
headline: 'site.thx.title',
subtitle: 'site.thx.checkEmail',
button: 'login',
linkTo: '/login',
},
login: {
headline: 'site.thx.errorTitle',
subtitle: 'site.thx.activateEmail',
},
}
export default {

View File

@ -147,7 +147,6 @@ Poco::JSON::Object* JsonUnsecureLogin::handle(Poco::Dynamic::Var params)
infos.add("set user.group_id to default group_id = 1");
case USER_NO_PRIVATE_KEY:
case USER_COMPLETE:
case USER_EMAIL_NOT_ACTIVATED:
result->set("state", "success");
result->set("user", session->getNewUser()->getJson());
result->set("session_id", session->getHandle());
@ -158,6 +157,10 @@ Poco::JSON::Object* JsonUnsecureLogin::handle(Poco::Dynamic::Var params)
AWAIT(hasElopageTask)
result->set("hasElopage", hasElopageTask->hasElopage());
return result;
case USER_EMAIL_NOT_ACTIVATED:
result->set("state", "processing");
result->set("msg", "user email not validated");
break;
default:
result->set("state", "error");
result->set("msg", "unknown user state");