diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a7716caab..c57da1964 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -422,7 +422,7 @@ jobs: report_name: Coverage Admin Interface type: lcov result_path: ./coverage/lcov.info - min_coverage: 81 + min_coverage: 93 token: ${{ github.token }} ############################################################################## diff --git a/admin/src/App.vue b/admin/src/App.vue index 40460eda4..bcaab2ef9 100644 --- a/admin/src/App.vue +++ b/admin/src/App.vue @@ -13,3 +13,11 @@ export default { components: { defaultLayout }, } + diff --git a/admin/src/components/UserTable.spec.js b/admin/src/components/UserTable.spec.js index 982b65a81..d900b126d 100644 --- a/admin/src/components/UserTable.spec.js +++ b/admin/src/components/UserTable.spec.js @@ -11,22 +11,36 @@ describe('UserTable', () => { const defaultItemsUser = [ { - email: 'bibi@bloxberg.de', + userId: 1, firstName: 'Bibi', lastName: 'Bloxberg', - creation: [1000, 1000, 1000], + email: 'bibi@bloxberg.de', + creation: [200, 400, 600], + emailChecked: true, }, { - email: 'bibi@bloxberg.de', - firstName: 'Bibi', - lastName: 'Bloxberg', + userId: 2, + firstName: 'Benjamin', + lastName: 'Blümchen', + email: 'benjamin@bluemchen.de', creation: [1000, 1000, 1000], + emailChecked: true, }, { - email: 'bibi@bloxberg.de', - firstName: 'Bibi', - lastName: 'Bloxberg', + userId: 3, + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + creation: [0, 0, 0], + emailChecked: true, + }, + { + userId: 4, + firstName: 'New', + lastName: 'User', + email: 'new@user.ch', creation: [1000, 1000, 1000], + emailChecked: false, }, ] @@ -107,7 +121,7 @@ describe('UserTable', () => { const mocks = { $t: jest.fn((t) => t), - $d: jest.fn((d) => d), + $d: jest.fn((d) => String(d)), $apollo: { query: apolloQueryMock, }, @@ -122,7 +136,7 @@ describe('UserTable', () => { describe('mount', () => { describe('type PageUserSearch', () => { - beforeEach(() => { + beforeEach(async () => { wrapper = Wrapper(propsDataPageUserSearch) }) @@ -175,12 +189,12 @@ describe('UserTable', () => { }) describe('content', () => { - it('has 3 rows', () => { - expect(wrapper.findAll('tbody tr').length).toBe(3) + it('has 4 rows', () => { + expect(wrapper.findAll('tbody tr')).toHaveLength(4) }) it('has 7 columns', () => { - expect(wrapper.findAll('tr:nth-child(1) > td').length).toBe(7) + expect(wrapper.findAll('tr:nth-child(1) > td')).toHaveLength(7) }) it('find button on fifth column', () => { @@ -189,6 +203,110 @@ describe('UserTable', () => { ).toBeTruthy() }) }) + + describe('row toggling', () => { + describe('user with email not activated', () => { + it('has no details button', () => { + expect( + wrapper.findAll('tbody > tr').at(3).findAll('td').at(4).find('button').exists(), + ).toBeFalsy() + }) + + it('has a red confirmed button with envelope item', () => { + const row = wrapper.findAll('tbody > tr').at(3) + expect(row.findAll('td').at(5).find('button').exists()).toBeTruthy() + expect(row.findAll('td').at(5).find('button').classes('btn-danger')).toBeTruthy() + expect(row.findAll('td').at(5).find('svg').classes('bi-envelope')).toBeTruthy() + }) + + describe('click on envelope', () => { + beforeEach(async () => { + await wrapper + .findAll('tbody > tr') + .at(3) + .findAll('td') + .at(5) + .find('button') + .trigger('click') + }) + + it('opens the details', async () => { + expect(wrapper.findAll('tbody > tr')).toHaveLength(6) + expect(wrapper.findAll('tbody > tr').at(5).find('input').element.value).toBe( + 'new@user.ch', + ) + expect(wrapper.findAll('tbody > tr').at(5).text()).toContain( + 'unregister_mail.text_false', + ) + // HACK: for some reason we need to close the row details after this test + await wrapper + .findAll('tbody > tr') + .at(3) + .findAll('td') + .at(5) + .find('button') + .trigger('click') + }) + + describe('click on envelope again', () => { + beforeEach(async () => { + await wrapper + .findAll('tbody > tr') + .at(3) + .findAll('td') + .at(5) + .find('button') + .trigger('click') + }) + + it('closes the details', () => { + expect(wrapper.findAll('tbody > tr')).toHaveLength(4) + }) + }) + + describe('click on close details', () => { + beforeEach(async () => { + await wrapper.findAll('tbody > tr').at(5).findAll('button').at(1).trigger('click') + }) + + it('closes the details', () => { + expect(wrapper.findAll('tbody > tr')).toHaveLength(4) + }) + }) + }) + }) + + describe('different details', () => { + it.skip('shows the creation formular for second user', async () => { + await wrapper + .findAll('tbody > tr') + .at(1) + .findAll('td') + .at(4) + .find('button') + .trigger('click') + expect(wrapper.findAll('tbody > tr')).toHaveLength(6) + expect( + wrapper + .findAll('tbody > tr') + .at(3) + .find('div.component-creation-formular') + .exists(), + ).toBeTruthy() + }) + + it.skip('shows the transactions for third user', async () => { + await wrapper + .findAll('tbody > tr') + .at(4) + .findAll('td') + .at(6) + .find('button') + .trigger('click') + expect(wrapper.findAll('tbody > tr')).toHaveLength(6) + }) + }) + }) }) }) diff --git a/admin/src/components/UserTable.vue b/admin/src/components/UserTable.vue index 4d14a35cb..e518ac6d1 100644 --- a/admin/src/components/UserTable.vue +++ b/admin/src/components/UserTable.vue @@ -27,15 +27,7 @@ - + @@ -125,7 +117,7 @@ - + @@ -187,15 +179,6 @@ export default { type: Array, required: true, }, - criteria: { - type: String, - required: false, - default: '', - }, - creation: { - type: Array, - required: false, - }, }, components: { CreationFormular, @@ -259,13 +242,6 @@ export default { this.overlayBookmarkType = bookmarkType this.overlayItem = item - if (bookmarkType === 'remove') { - this.overlayText.header = this.$t('overlay.remove.title') - this.overlayText.text1 = this.$t('overlay.remove.text') - this.overlayText.text2 = this.$t('overlay.remove.question') - this.overlayText.button_ok = this.$t('overlay.remove.yes') - this.overlayText.button_cancel = this.$t('overlay.remove.no') - } if (bookmarkType === 'confirm') { this.overlayText.header = this.$t('overlay.confirm.title') this.overlayText.text1 = this.$t('overlay.confirm.text') @@ -275,9 +251,6 @@ export default { } }, overlayOK(bookmarkType, item) { - if (bookmarkType === 'remove') { - this.bookmarkRemove(item) - } if (bookmarkType === 'confirm') { this.$emit('confirm-creation', item) } diff --git a/admin/src/locales/de.json b/admin/src/locales/de.json index 7585ee122..327cfd302 100644 --- a/admin/src/locales/de.json +++ b/admin/src/locales/de.json @@ -54,6 +54,7 @@ } }, "remove": "Entfernen", + "remove_all": "alle Nutzer entfernen", "transaction": "Transaktion", "transactionlist": { "amount": "Betrag", diff --git a/admin/src/locales/en.json b/admin/src/locales/en.json index 2680a1c00..3f12f10a8 100644 --- a/admin/src/locales/en.json +++ b/admin/src/locales/en.json @@ -54,6 +54,7 @@ } }, "remove": "Remove", + "remove_all": "Remove all users", "transaction": "Transaction", "transactionlist": { "amount": "Amount", diff --git a/admin/src/mixins/creationMonths.js b/admin/src/mixins/creationMonths.js index a2bbdcd1a..c26dc5b02 100644 --- a/admin/src/mixins/creationMonths.js +++ b/admin/src/mixins/creationMonths.js @@ -1,6 +1,9 @@ export const creationMonths = { props: { - creation: [1000, 1000, 1000], + creation: { + type: Array, + default: () => [1000, 1000, 1000], + }, }, computed: { creationDates() { @@ -31,5 +34,8 @@ export const creationMonths = { } }) }, + creationLabel() { + return this.creationDates.map((date) => this.$d(date, 'monthShort')).join(' | ') + }, }, } diff --git a/admin/src/pages/Creation.spec.js b/admin/src/pages/Creation.spec.js index 5ff3daac5..81d556e9b 100644 --- a/admin/src/pages/Creation.spec.js +++ b/admin/src/pages/Creation.spec.js @@ -1,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils' +import { mount } from '@vue/test-utils' import Creation from './Creation.vue' const localVue = global.localVue @@ -14,6 +14,7 @@ const apolloQueryMock = jest.fn().mockResolvedValue({ lastName: 'Bloxberg', email: 'bibi@bloxberg.de', creation: [200, 400, 600], + emailChecked: true, }, { userId: 2, @@ -21,6 +22,7 @@ const apolloQueryMock = jest.fn().mockResolvedValue({ lastName: 'Blümchen', email: 'benjamin@bluemchen.de', creation: [800, 600, 400], + emailChecked: true, }, ], }, @@ -51,10 +53,10 @@ describe('Creation', () => { let wrapper const Wrapper = () => { - return shallowMount(Creation, { localVue, mocks }) + return mount(Creation, { localVue, mocks }) } - describe('shallowMount', () => { + describe('mount', () => { beforeEach(() => { jest.clearAllMocks() wrapper = Wrapper() @@ -77,64 +79,66 @@ describe('Creation', () => { ) }) - it('sets the data of itemsList', () => { - expect(wrapper.vm.itemsList).toEqual([ - { - userId: 1, - firstName: 'Bibi', - lastName: 'Bloxberg', - email: 'bibi@bloxberg.de', - creation: [200, 400, 600], - showDetails: false, - }, - { - userId: 2, - firstName: 'Benjamin', - lastName: 'Blümchen', - email: 'benjamin@bluemchen.de', - creation: [800, 600, 400], - showDetails: false, - }, - ]) + it('has two rows in the left table', () => { + expect(wrapper.findAll('table').at(0).findAll('tbody > tr')).toHaveLength(2) + }) + + it('has nwo rows in the right table', () => { + expect(wrapper.findAll('table').at(1).findAll('tbody > tr')).toHaveLength(0) + }) + + it('has correct data in first row ', () => { + expect(wrapper.findAll('table').at(0).findAll('tbody > tr').at(0).text()).toContain('Bibi') + expect(wrapper.findAll('table').at(0).findAll('tbody > tr').at(0).text()).toContain( + 'Bloxberg', + ) + expect(wrapper.findAll('table').at(0).findAll('tbody > tr').at(0).text()).toContain( + '200 | 400 | 600', + ) + expect(wrapper.findAll('table').at(0).findAll('tbody > tr').at(0).text()).toContain( + 'bibi@bloxberg.de', + ) + }) + + it('has correct data in second row ', () => { + expect(wrapper.findAll('table').at(0).findAll('tbody > tr').at(1).text()).toContain( + 'Benjamin', + ) + expect(wrapper.findAll('table').at(0).findAll('tbody > tr').at(1).text()).toContain( + 'Blümchen', + ) + expect(wrapper.findAll('table').at(0).findAll('tbody > tr').at(1).text()).toContain( + '800 | 600 | 400', + ) + expect(wrapper.findAll('table').at(0).findAll('tbody > tr').at(1).text()).toContain( + 'benjamin@bluemchen.de', + ) }) }) describe('push item', () => { beforeEach(() => { - wrapper.findComponent({ name: 'UserTable' }).vm.$emit('push-item', { - userId: 2, - firstName: 'Benjamin', - lastName: 'Blümchen', - email: 'benjamin@bluemchen.de', - creation: [800, 600, 400], - showDetails: false, - }) + wrapper.findAll('table').at(0).findAll('tbody > tr').at(1).find('button').trigger('click') }) - it('removes the pushed item from itemsList', () => { - expect(wrapper.vm.itemsList).toEqual([ - { - userId: 1, - firstName: 'Bibi', - lastName: 'Bloxberg', - email: 'bibi@bloxberg.de', - creation: [200, 400, 600], - showDetails: false, - }, - ]) + it('has one item in left table', () => { + expect(wrapper.findAll('table').at(0).findAll('tbody > tr')).toHaveLength(1) }) - it('adds the pushed item to itemsMassCreation', () => { - expect(wrapper.vm.itemsMassCreation).toEqual([ - { - userId: 2, - firstName: 'Benjamin', - lastName: 'Blümchen', - email: 'benjamin@bluemchen.de', - creation: [800, 600, 400], - showDetails: false, - }, - ]) + it('has one item in right table', () => { + expect(wrapper.findAll('table').at(1).findAll('tbody > tr')).toHaveLength(1) + }) + + it('has the correct user in left table', () => { + expect(wrapper.findAll('table').at(0).findAll('tbody > tr').at(0).text()).toContain( + 'bibi@bloxberg.de', + ) + }) + + it('has the correct user in right table', () => { + expect(wrapper.findAll('table').at(1).findAll('tbody > tr').at(0).text()).toContain( + 'benjamin@bluemchen.de', + ) }) it('updates userSelectedInMassCreation in store', () => { @@ -146,88 +150,58 @@ describe('Creation', () => { email: 'benjamin@bluemchen.de', creation: [800, 600, 400], showDetails: false, - }, - ]) - }) - }) - - describe('remove item', () => { - beforeEach(async () => { - await wrapper.findComponent({ name: 'UserTable' }).vm.$emit('push-item', { - userId: 2, - firstName: 'Benjamin', - lastName: 'Blümchen', - email: 'benjamin@bluemchen.de', - creation: [800, 600, 400], - showDetails: false, - }) - await wrapper - .findAllComponents({ name: 'UserTable' }) - .at(1) - .vm.$emit('remove-item', { - userId: 2, - firstName: 'Benjamin', - lastName: 'Blümchen', - email: 'benjamin@bluemchen.de', - creation: [800, 600, 400], - showDetails: false, - }) - }) - - it('adds the removed item to itemsList', () => { - expect(wrapper.vm.itemsList).toEqual([ - { - userId: 2, - firstName: 'Benjamin', - lastName: 'Blümchen', - email: 'benjamin@bluemchen.de', - creation: [800, 600, 400], - showDetails: false, - }, - { - userId: 1, - firstName: 'Bibi', - lastName: 'Bloxberg', - email: 'bibi@bloxberg.de', - creation: [200, 400, 600], - showDetails: false, + emailChecked: true, }, ]) }) - it('removes the item from itemsMassCreation', () => { - expect(wrapper.vm.itemsMassCreation).toEqual([]) - }) - - it('commits empty array as userSelectedInMassCreation', () => { - expect(storeCommitMock).toBeCalledWith('setUserSelectedInMassCreation', []) - }) - }) - - describe('remove all bookmarks', () => { - beforeEach(async () => { - await wrapper.findComponent({ name: 'UserTable' }).vm.$emit('push-item', { - userId: 2, - firstName: 'Benjamin', - lastName: 'Blümchen', - email: 'benjamin@bluemchen.de', - creation: [800, 600, 400], - showDetails: false, + describe('remove item', () => { + beforeEach(async () => { + await wrapper + .findAll('table') + .at(1) + .findAll('tbody > tr') + .at(0) + .find('button') + .trigger('click') + }) + + it('has two items in left table', () => { + expect(wrapper.findAll('table').at(0).findAll('tbody > tr')).toHaveLength(2) + }) + + it('has the removed user in first row', () => { + expect(wrapper.findAll('table').at(0).findAll('tbody > tr').at(0).text()).toContain( + 'benjamin@bluemchen.de', + ) + }) + + it('has no items in right table', () => { + expect(wrapper.findAll('table').at(1).findAll('tbody > tr')).toHaveLength(0) + }) + + it('commits empty array as userSelectedInMassCreation', () => { + expect(storeCommitMock).toBeCalledWith('setUserSelectedInMassCreation', []) }) - jest.clearAllMocks() - wrapper.findComponent({ name: 'CreationFormular' }).vm.$emit('remove-all-bookmark') }) - it('removes all items from itemsMassCreation', () => { - expect(wrapper.vm.itemsMassCreation).toEqual([]) - }) + describe('remove all bookmarks', () => { + beforeEach(async () => { + jest.clearAllMocks() + await wrapper.find('button.btn-light').trigger('click') + }) - it('commits empty array to userSelectedInMassCreation', () => { - expect(storeCommitMock).toBeCalledWith('setUserSelectedInMassCreation', []) - }) + it('has no items in right table', () => { + expect(wrapper.findAll('table').at(1).findAll('tbody > tr')).toHaveLength(0) + }) - it('calls searchUsers', () => { - expect(apolloQueryMock).toBeCalled() + it('commits empty array to userSelectedInMassCreation', () => { + expect(storeCommitMock).toBeCalledWith('setUserSelectedInMassCreation', []) + }) + + it('calls searchUsers', () => { + expect(apolloQueryMock).toBeCalled() + }) }) }) @@ -241,22 +215,24 @@ describe('Creation', () => { email: 'benjamin@bluemchen.de', creation: [800, 600, 400], showDetails: false, + emailChecked: true, }, ] wrapper = Wrapper() }) - it('has only one item itemsList', () => { - expect(wrapper.vm.itemsList).toEqual([ - { - userId: 1, - firstName: 'Bibi', - lastName: 'Bloxberg', - email: 'bibi@bloxberg.de', - creation: [200, 400, 600], - showDetails: false, - }, - ]) + it('has one item in left table', () => { + expect(wrapper.findAll('table').at(0).findAll('tbody > tr')).toHaveLength(1) + }) + + it('has one item in right table', () => { + expect(wrapper.findAll('table').at(1).findAll('tbody > tr')).toHaveLength(1) + }) + + it('has the stored user in second row', () => { + expect(wrapper.findAll('table').at(1).findAll('tbody > tr').at(0).text()).toContain( + 'benjamin@bluemchen.de', + ) }) }) @@ -265,17 +241,38 @@ describe('Creation', () => { jest.clearAllMocks() }) - it('calls API when criteria changes', async () => { - await wrapper.setData({ criteria: 'XX' }) - expect(apolloQueryMock).toBeCalledWith( - expect.objectContaining({ - variables: { - searchText: 'XX', - currentPage: 1, - pageSize: 25, - }, - }), - ) + describe('search criteria', () => { + beforeEach(async () => { + await wrapper.setData({ criteria: 'XX' }) + }) + + it('calls API when criteria changes', async () => { + expect(apolloQueryMock).toBeCalledWith( + expect.objectContaining({ + variables: { + searchText: 'XX', + currentPage: 1, + pageSize: 25, + }, + }), + ) + }) + + describe('reset search criteria', () => { + it('calls the API', async () => { + jest.clearAllMocks() + await wrapper.find('.test-click-clear-criteria').trigger('click') + expect(apolloQueryMock).toBeCalledWith( + expect.objectContaining({ + variables: { + searchText: '', + currentPage: 1, + pageSize: 25, + }, + }), + ) + }) + }) }) it('calls API when currentPage changes', async () => { diff --git a/admin/src/pages/Creation.vue b/admin/src/pages/Creation.vue index 0e2cb541e..64efab997 100644 --- a/admin/src/pages/Creation.vue +++ b/admin/src/pages/Creation.vue @@ -3,19 +3,25 @@ Usersuche - + + + + + + + + + - + + + + + + {{ $t('remove_all') }} + + + + {{ $t('multiple_creation_text') }} @@ -45,7 +57,7 @@ type="massCreation" :creation="creation" :items="itemsMassCreation" - @remove-all-bookmark="removeAllBookmark" + @remove-all-bookmark="removeAllBookmarks" /> @@ -55,9 +67,11 @@ import CreationFormular from '../components/CreationFormular.vue' import UserTable from '../components/UserTable.vue' import { searchUsers } from '../graphql/searchUsers' +import { creationMonths } from '../mixins/creationMonths' export default { name: 'Creation', + mixins: [creationMonths], components: { CreationFormular, UserTable, @@ -69,7 +83,6 @@ export default { itemsMassCreation: this.$store.state.userSelectedInMassCreation, radioSelectedMass: '', criteria: '', - creation: [null, null, null], rows: 0, currentPage: 1, perPage: 25, @@ -126,7 +139,7 @@ export default { ) this.$store.commit('setUserSelectedInMassCreation', this.itemsMassCreation) }, - removeAllBookmark() { + removeAllBookmarks() { this.itemsMassCreation = [] this.$store.commit('setUserSelectedInMassCreation', []) this.getUsers() @@ -163,16 +176,6 @@ export default { { key: 'bookmark', label: this.$t('remove') }, ] }, - creationLabel() { - const now = new Date(this.now) - const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1) - const beforeLastMonth = new Date(now.getFullYear(), now.getMonth() - 2, 1) - return [ - this.$d(beforeLastMonth, 'monthShort'), - this.$d(lastMonth, 'monthShort'), - this.$d(now, 'monthShort'), - ].join(' | ') - }, }, watch: { currentPage() { diff --git a/admin/src/pages/CreationConfirm.spec.js b/admin/src/pages/CreationConfirm.spec.js index 5768e1078..2520fd37b 100644 --- a/admin/src/pages/CreationConfirm.spec.js +++ b/admin/src/pages/CreationConfirm.spec.js @@ -78,6 +78,7 @@ describe('CreationConfirm', () => { it('commits resetOpenCreations to store', () => { expect(storeCommitMock).toBeCalledWith('resetOpenCreations') }) + it('commits setOpenCreations to store', () => { expect(storeCommitMock).toBeCalledWith('setOpenCreations', 2) }) @@ -85,7 +86,7 @@ describe('CreationConfirm', () => { describe('remove creation with success', () => { beforeEach(async () => { - await wrapper.findComponent({ name: 'UserTable' }).vm.$emit('remove-creation', { id: 1 }) + await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click') }) it('calls the deletePendingCreation mutation', () => { @@ -107,7 +108,7 @@ describe('CreationConfirm', () => { describe('remove creation with error', () => { beforeEach(async () => { apolloMutateMock.mockRejectedValue({ message: 'Ouchhh!' }) - await wrapper.findComponent({ name: 'UserTable' }).vm.$emit('remove-creation', { id: 1 }) + await wrapper.findAll('tr').at(1).findAll('button').at(0).trigger('click') }) it('toasts an error message', () => { @@ -118,22 +119,52 @@ describe('CreationConfirm', () => { describe('confirm creation with success', () => { beforeEach(async () => { apolloMutateMock.mockResolvedValue({}) - await wrapper.findComponent({ name: 'UserTable' }).vm.$emit('confirm-creation', { id: 2 }) + await wrapper.findAll('tr').at(2).findAll('button').at(2).trigger('click') }) - it('calls the confirmPendingCreation mutation', () => { - expect(apolloMutateMock).toBeCalledWith({ - mutation: confirmPendingCreation, - variables: { id: 2 }, + describe('overlay', () => { + it('opens the overlay', () => { + expect(wrapper.find('#overlay').isVisible()).toBeTruthy() }) - }) - it('commits openCreationsMinus to store', () => { - expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1) - }) + describe('cancel confirmation', () => { + beforeEach(async () => { + await wrapper.find('#overlay').findAll('button').at(0).trigger('click') + }) - it('toasts a success message', () => { - expect(toastedSuccessMock).toBeCalledWith('creation_form.toasted_created') + it('closes the overlay', () => { + expect(wrapper.find('#overlay').isVisible()).toBeFalsy() + }) + + it('still has 2 items in the table', () => { + expect(wrapper.findAll('tbody > tr')).toHaveLength(2) + }) + }) + + describe('confirm creation', () => { + beforeEach(async () => { + await wrapper.find('#overlay').findAll('button').at(1).trigger('click') + }) + + it('calls the confirmPendingCreation mutation', () => { + expect(apolloMutateMock).toBeCalledWith({ + mutation: confirmPendingCreation, + variables: { id: 2 }, + }) + }) + + it('commits openCreationsMinus to store', () => { + expect(storeCommitMock).toBeCalledWith('openCreationsMinus', 1) + }) + + it('toasts a success message', () => { + expect(toastedSuccessMock).toBeCalledWith('creation_form.toasted_created') + }) + + it('has 1 item left in the table', () => { + expect(wrapper.findAll('tbody > tr')).toHaveLength(1) + }) + }) }) }) diff --git a/admin/src/pages/UserSearch.spec.js b/admin/src/pages/UserSearch.spec.js index f7df62730..fe7bde0cc 100644 --- a/admin/src/pages/UserSearch.spec.js +++ b/admin/src/pages/UserSearch.spec.js @@ -9,10 +9,35 @@ const apolloQueryMock = jest.fn().mockResolvedValue({ userCount: 1, userList: [ { + userId: 1, firstName: 'Bibi', lastName: 'Bloxberg', email: 'bibi@bloxberg.de', creation: [200, 400, 600], + emailChecked: true, + }, + { + userId: 2, + firstName: 'Benjamin', + lastName: 'Blümchen', + email: 'benjamin@bluemchen.de', + creation: [1000, 1000, 1000], + emailChecked: true, + }, + { + userId: 3, + firstName: 'Peter', + lastName: 'Lustig', + email: 'peter@lustig.de', + creation: [0, 0, 0], + emailChecked: true, + }, + { + userId: 4, + firstName: 'New', + lastName: 'User', + email: 'new@user.ch', + creation: [1000, 1000, 1000], emailChecked: false, }, ], @@ -24,7 +49,7 @@ const toastErrorMock = jest.fn() const mocks = { $t: jest.fn((t) => t), - $d: jest.fn((d) => d), + $d: jest.fn((d) => String(d)), $apollo: { query: apolloQueryMock, }, @@ -42,6 +67,7 @@ describe('UserSearch', () => { describe('mount', () => { beforeEach(() => { + jest.clearAllMocks() wrapper = Wrapper() }) @@ -49,13 +75,90 @@ describe('UserSearch', () => { expect(wrapper.find('div.user-search').exists()).toBeTruthy() }) + it('calls the API', () => { + expect(apolloQueryMock).toBeCalledWith( + expect.objectContaining({ + variables: { + searchText: '', + currentPage: 1, + pageSize: 25, + notActivated: false, + }, + }), + ) + }) + describe('unconfirmed emails', () => { beforeEach(async () => { await wrapper.find('button.btn-block').trigger('click') }) - it('filters the users by unconfirmed emails', () => { - expect(wrapper.vm.searchResult).toHaveLength(1) + it('calls API with filter', () => { + expect(apolloQueryMock).toBeCalledWith( + expect.objectContaining({ + variables: { + searchText: '', + currentPage: 1, + pageSize: 25, + notActivated: true, + }, + }), + ) + }) + }) + + describe('pagination', () => { + beforeEach(async () => { + wrapper.setData({ currentPage: 2 }) + }) + + it('calls the API with new page', () => { + expect(apolloQueryMock).toBeCalledWith( + expect.objectContaining({ + variables: { + searchText: '', + currentPage: 2, + pageSize: 25, + notActivated: false, + }, + }), + ) + }) + }) + + describe('user search', () => { + beforeEach(async () => { + wrapper.setData({ criteria: 'search string' }) + }) + + it('calls the API with search string', () => { + expect(apolloQueryMock).toBeCalledWith( + expect.objectContaining({ + variables: { + searchText: 'search string', + currentPage: 1, + pageSize: 25, + notActivated: false, + }, + }), + ) + }) + + describe('reset the search field', () => { + it('calls the API with empty criteria', async () => { + jest.clearAllMocks() + await wrapper.find('.test-click-clear-criteria').trigger('click') + expect(apolloQueryMock).toBeCalledWith( + expect.objectContaining({ + variables: { + searchText: '', + currentPage: 1, + pageSize: 25, + notActivated: false, + }, + }), + ) + }) }) }) diff --git a/admin/src/pages/UserSearch.vue b/admin/src/pages/UserSearch.vue index b7c19d03a..28e1a7774 100644 --- a/admin/src/pages/UserSearch.vue +++ b/admin/src/pages/UserSearch.vue @@ -7,20 +7,22 @@ {{ $t('user_search') }} - - - + + + + + + + + + + + import UserTable from '../components/UserTable.vue' import { searchUsers } from '../graphql/searchUsers' +import { creationMonths } from '../mixins/creationMonths' export default { name: 'UserSearch', + mixins: [creationMonths], components: { UserTable, }, @@ -83,16 +87,11 @@ export default { currentPage() { this.getUsers() }, + criteria() { + this.getUsers() + }, }, computed: { - lastMonthDate() { - const now = new Date(this.now) - return new Date(now.getFullYear(), now.getMonth() - 1, 1) - }, - beforeLastMonthDate() { - const now = new Date(this.now) - return new Date(now.getFullYear(), now.getMonth() - 2, 1) - }, fields() { return [ { key: 'email', label: this.$t('e_mail') }, @@ -100,11 +99,7 @@ export default { { key: 'lastName', label: this.$t('lastname') }, { key: 'creation', - label: [ - this.$d(this.beforeLastMonthDate, 'monthShort'), - this.$d(this.lastMonthDate, 'monthShort'), - this.$d(this.now, 'monthShort'), - ].join(' | '), + label: this.creationLabel, formatter: (value, key, item) => { return value.join(' | ') }, diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 99859b252..f9707c711 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -4,7 +4,7 @@ import dotenv from 'dotenv' dotenv.config() const constants = { - DB_VERSION: '0016-transaction_signatures', + DB_VERSION: '0019-replace_login_user_id_with_state_user_id', } const server = { diff --git a/backend/src/graphql/resolver/AdminResolver.ts b/backend/src/graphql/resolver/AdminResolver.ts index 4204c6c2f..2c8cbfe27 100644 --- a/backend/src/graphql/resolver/AdminResolver.ts +++ b/backend/src/graphql/resolver/AdminResolver.ts @@ -21,8 +21,8 @@ import { UserTransaction } from '@entity/UserTransaction' import { UserTransactionRepository } from '../../typeorm/repository/UserTransaction' import { BalanceRepository } from '../../typeorm/repository/Balance' import { calculateDecay } from '../../util/decay' -import { LoginUserRepository } from '../../typeorm/repository/LoginUser' import { AdminPendingCreation } from '@entity/AdminPendingCreation' +import { User as dbUser } from '@entity/User' @Resolver() export class AdminResolver { @@ -378,7 +378,6 @@ function isCreationValid(creations: number[], amount: number, creationDate: Date } async function hasActivatedEmail(email: string): Promise { - const repository = getCustomRepository(LoginUserRepository) - const user = await repository.findByEmail(email) + const user = await dbUser.findOne({ email }) return user ? user.emailChecked : false } diff --git a/backend/src/graphql/resolver/TransactionResolver.ts b/backend/src/graphql/resolver/TransactionResolver.ts index 61f590123..4bf4e7a17 100644 --- a/backend/src/graphql/resolver/TransactionResolver.ts +++ b/backend/src/graphql/resolver/TransactionResolver.ts @@ -33,7 +33,6 @@ import { calculateDecay, calculateDecayWithInterval } from '../../util/decay' 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' // Helper function @@ -290,14 +289,13 @@ async function addUserTransaction( } async function getPublicKey(email: string): Promise { - const loginUserRepository = getCustomRepository(LoginUserRepository) - const loginUser = await loginUserRepository.findOne({ email: email }) + const user = await dbUser.findOne({ email: email }) // User not found - if (!loginUser) { + if (!user) { return null } - return loginUser.pubKey.toString('hex') + return user.pubKey.toString('hex') } @Resolver() @@ -364,7 +362,7 @@ export class TransactionResolver { // validate sender user (logged in) const userRepository = getCustomRepository(UserRepository) const senderUser = await userRepository.findByPubkeyHex(context.pubKey) - if (senderUser.pubkey.length !== 32) { + if (senderUser.pubKey.length !== 32) { throw new Error('invalid sender public key') } if (!hasUserAmount(senderUser, amount)) { @@ -454,7 +452,7 @@ export class TransactionResolver { const transactionSendCoin = new dbTransactionSendCoin() transactionSendCoin.transactionId = transaction.id transactionSendCoin.userId = senderUser.id - transactionSendCoin.senderPublic = senderUser.pubkey + transactionSendCoin.senderPublic = senderUser.pubKey transactionSendCoin.recipiantUserId = recipiantUser.id transactionSendCoin.recipiantPublic = Buffer.from(recipiantPublicKey, 'hex') transactionSendCoin.amount = centAmount diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 14a56b60b..d7ecfa797 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -14,11 +14,8 @@ import UnsecureLoginArgs from '../arg/UnsecureLoginArgs' import UpdateUserInfosArgs from '../arg/UpdateUserInfosArgs' import { klicktippNewsletterStateMiddleware } from '../../middleware/klicktippMiddleware' 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 { LoginUserBackup } from '@entity/LoginUserBackup' import { LoginEmailOptIn } from '@entity/LoginEmailOptIn' import { sendResetPasswordEmail } from '../../mailer/sendResetPasswordEmail' import { sendAccountActivationEmail } from '../../mailer/sendAccountActivationEmail' @@ -27,7 +24,6 @@ import { klicktippSignIn } from '../../apis/KlicktippController' import { RIGHTS } from '../../auth/RIGHTS' import { ServerUserRepository } from '../../typeorm/repository/ServerUser' import { ROLE_ADMIN } from '../../auth/ROLES' -import { randomBytes } from 'crypto' const EMAIL_OPT_IN_RESET_PASSWORD = 2 const EMAIL_OPT_IN_REGISTER = 1 @@ -186,10 +182,10 @@ const createEmailOptIn = async ( return emailOptIn } -const getOptInCode = async (loginUser: LoginUser): Promise => { +const getOptInCode = async (loginUserId: number): Promise => { const loginEmailOptInRepository = await getRepository(LoginEmailOptIn) let optInCode = await loginEmailOptInRepository.findOne({ - userId: loginUser.id, + userId: loginUserId, emailOptInTypeId: EMAIL_OPT_IN_RESET_PASSWORD, }) @@ -207,7 +203,7 @@ const getOptInCode = async (loginUser: LoginUser): Promise => { } else { optInCode = new LoginEmailOptIn() optInCode.verificationCode = random(64) - optInCode.userId = loginUser.id + optInCode.userId = loginUserId optInCode.emailOptInTypeId = EMAIL_OPT_IN_RESET_PASSWORD } await loginEmailOptInRepository.save(optInCode) @@ -223,17 +219,15 @@ export class UserResolver { // 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.id = userEntity.id 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 + user.description = userEntity.description + user.pubkey = userEntity.pubKey.toString('hex') + user.language = userEntity.language // Elopage Status & Stored PublisherId user.hasElopage = await this.hasElopage(context) @@ -259,76 +253,50 @@ export class UserResolver { @Ctx() context: any, ): Promise { email = email.trim().toLowerCase() - const loginUserRepository = getCustomRepository(LoginUserRepository) - const loginUser = await loginUserRepository.findByEmail(email).catch(() => { + const dbUser = await DbUser.findOneOrFail({ email }).catch(() => { throw new Error('No user with this credentials') }) - if (!loginUser.emailChecked) { + if (!dbUser.emailChecked) { throw new Error('User email not validated') } - if (loginUser.password === BigInt(0)) { + if (dbUser.password === BigInt(0)) { // TODO we want to catch this on the frontend and ask the user to check his emails or resend code throw new Error('User has no password set yet') } - if (!loginUser.pubKey || !loginUser.privKey) { + if (!dbUser.pubKey || !dbUser.privKey) { // TODO we want to catch this on the frontend and ask the user to check his emails or resend code throw new Error('User has no private or publicKey') } const passwordHash = SecretKeyCryptographyCreateKey(email, password) // return short and long hash - const loginUserPassword = BigInt(loginUser.password.toString()) + const loginUserPassword = BigInt(dbUser.password.toString()) if (loginUserPassword !== passwordHash[0].readBigUInt64LE()) { throw new Error('No user with this credentials') } - // TODO: If user has no pubKey Create it again and update user. - - const userRepository = getCustomRepository(UserRepository) - let userEntity: void | DbUser - 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 = 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') - }) - }) - if (!userEntity) { - throw new Error('error with cannot happen') - } const user = new User() - user.id = userEntity.id + user.id = dbUser.id 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 + user.firstName = dbUser.firstName + user.lastName = dbUser.lastName + user.username = dbUser.username + user.description = dbUser.description + user.pubkey = dbUser.pubKey.toString('hex') + user.language = dbUser.language // Elopage Status & Stored PublisherId - user.hasElopage = await this.hasElopage({ pubKey: loginUserPubKeyString }) + user.hasElopage = await this.hasElopage({ pubKey: dbUser.pubKey.toString('hex') }) 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) + dbUser.publisherId = publisherId + DbUser.save(dbUser) } // coinAnimation const userSettingRepository = getCustomRepository(UserSettingRepository) const coinanimation = await userSettingRepository - .readBoolean(userEntity.id, Setting.COIN_ANIMATION) + .readBoolean(dbUser.id, Setting.COIN_ANIMATION) .catch((error) => { throw new Error(error) }) @@ -341,7 +309,7 @@ export class UserResolver { context.setHeaders.push({ key: 'token', - value: encode(loginUser.pubKey), + value: encode(dbUser.pubKey), }) return user @@ -393,18 +361,21 @@ export class UserResolver { // const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1]) const emailHash = getEmailHash(email) - // Table: login_users - const loginUser = new LoginUser() - loginUser.email = email - loginUser.firstName = firstName - loginUser.lastName = lastName - loginUser.username = username - loginUser.description = '' + // Table: state_users + const dbUser = new DbUser() + dbUser.email = email + dbUser.firstName = firstName + dbUser.lastName = lastName + dbUser.username = username + dbUser.description = '' + dbUser.emailHash = emailHash + dbUser.language = language + dbUser.publisherId = publisherId + dbUser.passphrase = passphrase.join(' ') + // TODO this field has no null allowed unlike the loginServer table + // dbUser.pubKey = Buffer.from(randomBytes(32)) // Buffer.alloc(32, 0) default to 0000... + // dbUser.pubkey = keyPair[0] // loginUser.password = passwordHash[0].readBigUInt64LE() // using the shorthash - loginUser.emailHash = emailHash - loginUser.language = language - loginUser.groupId = 1 - loginUser.publisherId = publisherId // loginUser.pubKey = keyPair[0] // loginUser.privKey = encryptedPrivkey @@ -412,43 +383,15 @@ export class UserResolver { await queryRunner.connect() await queryRunner.startTransaction('READ UNCOMMITTED') try { - const { id: loginUserId } = await queryRunner.manager.save(loginUser).catch((error) => { + await queryRunner.manager.save(dbUser).catch((error) => { // eslint-disable-next-line no-console - console.log('insert LoginUser failed', error) - throw new Error('insert user failed') - }) - - // Table: login_user_backups - const loginUserBackup = new LoginUserBackup() - loginUserBackup.userId = loginUserId - loginUserBackup.passphrase = passphrase.join(' ') // login server saves trailing space - loginUserBackup.mnemonicType = 2 // ServerConfig::MNEMONIC_BIP0039_SORTED_ORDER; - - await queryRunner.manager.save(loginUserBackup).catch((error) => { - // eslint-disable-next-line no-console - console.log('insert LoginUserBackup failed', error) - throw new Error('insert user backup failed') - }) - - // Table: state_users - const dbUser = new DbUser() - dbUser.email = email - dbUser.firstName = firstName - dbUser.lastName = lastName - dbUser.username = username - // TODO this field has no null allowed unlike the loginServer table - dbUser.pubkey = Buffer.from(randomBytes(32)) // Buffer.alloc(32, 0) default to 0000... - // dbUser.pubkey = keyPair[0] - - await queryRunner.manager.save(dbUser).catch((er) => { - // eslint-disable-next-line no-console - console.log('Error while saving dbUser', er) + console.log('Error while saving dbUser', error) throw new Error('error saving user') }) // Store EmailOptIn in DB // TODO: this has duplicate code with sendResetPasswordEmail - const emailOptIn = await createEmailOptIn(loginUserId, queryRunner) + const emailOptIn = await createEmailOptIn(dbUser.id, queryRunner) const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace( /{code}/g, @@ -480,15 +423,14 @@ export class UserResolver { @Authorized([RIGHTS.SEND_ACTIVATION_EMAIL]) @Mutation(() => Boolean) async sendActivationEmail(@Arg('email') email: string): Promise { - const loginUserRepository = getCustomRepository(LoginUserRepository) - const loginUser = await loginUserRepository.findOneOrFail({ email: email }) + const user = await DbUser.findOneOrFail({ email: email }) const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() await queryRunner.startTransaction('READ UNCOMMITTED') try { - const emailOptIn = await createEmailOptIn(loginUser.id, queryRunner) + const emailOptIn = await createEmailOptIn(user.id, queryRunner) const activationLink = CONFIG.EMAIL_LINK_VERIFICATION.replace( /{code}/g, @@ -497,8 +439,8 @@ export class UserResolver { const emailSent = await sendAccountActivationEmail({ link: activationLink, - firstName: loginUser.firstName, - lastName: loginUser.lastName, + firstName: user.firstName, + lastName: user.lastName, email, }) @@ -522,10 +464,9 @@ export class UserResolver { async sendResetPasswordEmail(@Arg('email') email: string): Promise { // TODO: this has duplicate code with createUser - const loginUserRepository = await getCustomRepository(LoginUserRepository) - const loginUser = await loginUserRepository.findOneOrFail({ email }) + const user = await DbUser.findOneOrFail({ email }) - const optInCode = await getOptInCode(loginUser) + const optInCode = await getOptInCode(user.id) const link = CONFIG.EMAIL_LINK_SETPASSWORD.replace( /{code}/g, @@ -534,8 +475,8 @@ export class UserResolver { const emailSent = await sendResetPasswordEmail({ link, - firstName: loginUser.firstName, - lastName: loginUser.lastName, + firstName: user.firstName, + lastName: user.lastName, email, }) @@ -575,34 +516,18 @@ export class UserResolver { throw new Error('Code is older than 10 minutes') } - // load loginUser - const loginUserRepository = await getCustomRepository(LoginUserRepository) - const loginUser = await loginUserRepository - .findOneOrFail({ id: optInCode.userId }) - .catch(() => { - throw new Error('Could not find corresponding Login User') - }) - // load user - const dbUserRepository = await getCustomRepository(UserRepository) - const dbUser = await dbUserRepository.findOneOrFail({ email: loginUser.email }).catch(() => { - throw new Error('Could not find corresponding User') + const user = await DbUser.findOneOrFail({ id: optInCode.userId }).catch(() => { + throw new Error('Could not find corresponding Login User') }) - const loginUserBackupRepository = await getRepository(LoginUserBackup) - let loginUserBackup = await loginUserBackupRepository.findOne({ userId: loginUser.id }) - // Generate Passphrase if needed - if (!loginUserBackup) { + if (!user.passphrase) { const passphrase = PassphraseGenerate() - loginUserBackup = new LoginUserBackup() - loginUserBackup.userId = loginUser.id - loginUserBackup.passphrase = passphrase.join(' ') // login server saves trailing space - loginUserBackup.mnemonicType = 2 // ServerConfig::MNEMONIC_BIP0039_SORTED_ORDER; - loginUserBackupRepository.save(loginUserBackup) + user.passphrase = passphrase.join(' ') } - const passphrase = loginUserBackup.passphrase.split(' ') + const passphrase = user.passphrase.split(' ') if (passphrase.length < PHRASE_WORD_COUNT) { // TODO if this can happen we cannot recover from that // this seem to be good on production data, if we dont @@ -611,29 +536,23 @@ export class UserResolver { } // Activate EMail - loginUser.emailChecked = true + user.emailChecked = true // Update Password - const passwordHash = SecretKeyCryptographyCreateKey(loginUser.email, password) // return short and long hash + const passwordHash = SecretKeyCryptographyCreateKey(user.email, password) // return short and long hash const keyPair = KeyPairEd25519Create(passphrase) // return pub, priv Key const encryptedPrivkey = SecretKeyCryptographyEncrypt(keyPair[1], passwordHash[1]) - loginUser.password = passwordHash[0].readBigUInt64LE() // using the shorthash - loginUser.pubKey = keyPair[0] - loginUser.privKey = encryptedPrivkey - dbUser.pubkey = keyPair[0] + user.password = passwordHash[0].readBigUInt64LE() // using the shorthash + user.pubKey = keyPair[0] + user.privKey = encryptedPrivkey const queryRunner = getConnection().createQueryRunner() await queryRunner.connect() await queryRunner.startTransaction('READ UNCOMMITTED') try { - // Save loginUser - await queryRunner.manager.save(loginUser).catch((error) => { - throw new Error('error saving loginUser: ' + error) - }) - // Save user - await queryRunner.manager.save(dbUser).catch((error) => { + await queryRunner.manager.save(user).catch((error) => { throw new Error('error saving user: ' + error) }) @@ -654,12 +573,7 @@ export class UserResolver { // TODO do we always signUp the user? How to handle things with old users? if (optInCode.emailOptInTypeId === EMAIL_OPT_IN_REGISTER) { try { - await klicktippSignIn( - loginUser.email, - loginUser.language, - loginUser.firstName, - loginUser.lastName, - ) + await klicktippSignIn(user.email, user.language, user.firstName, user.lastName) } catch { // TODO is this a problem? // eslint-disable-next-line no-console @@ -689,8 +603,6 @@ export class UserResolver { ): Promise { const userRepository = getCustomRepository(UserRepository) const userEntity = await userRepository.findByPubkeyHex(context.pubKey) - const loginUserRepository = getCustomRepository(LoginUserRepository) - const loginUser = await loginUserRepository.findOneOrFail({ email: userEntity.email }) if (username) { throw new Error('change username currently not supported!') @@ -704,46 +616,44 @@ export class UserResolver { } if (firstName) { - loginUser.firstName = firstName userEntity.firstName = firstName } if (lastName) { - loginUser.lastName = lastName userEntity.lastName = lastName } if (description) { - loginUser.description = description + userEntity.description = description } if (language) { if (!isLanguage(language)) { throw new Error(`"${language}" isn't a valid language`) } - loginUser.language = language + userEntity.language = language } if (password && passwordNew) { // TODO: This had some error cases defined - like missing private key. This is no longer checked. - const oldPasswordHash = SecretKeyCryptographyCreateKey(loginUser.email, password) - if (BigInt(loginUser.password.toString()) !== oldPasswordHash[0].readBigUInt64LE()) { + const oldPasswordHash = SecretKeyCryptographyCreateKey(userEntity.email, password) + if (BigInt(userEntity.password.toString()) !== oldPasswordHash[0].readBigUInt64LE()) { throw new Error(`Old password is invalid`) } - const privKey = SecretKeyCryptographyDecrypt(loginUser.privKey, oldPasswordHash[1]) + const privKey = SecretKeyCryptographyDecrypt(userEntity.privKey, oldPasswordHash[1]) - const newPasswordHash = SecretKeyCryptographyCreateKey(loginUser.email, passwordNew) // return short and long hash + const newPasswordHash = SecretKeyCryptographyCreateKey(userEntity.email, passwordNew) // return short and long hash const encryptedPrivkey = SecretKeyCryptographyEncrypt(privKey, newPasswordHash[1]) // Save new password hash and newly encrypted private key - loginUser.password = newPasswordHash[0].readBigUInt64LE() - loginUser.privKey = encryptedPrivkey + userEntity.password = newPasswordHash[0].readBigUInt64LE() + userEntity.privKey = encryptedPrivkey } // Save publisherId only if Elopage is not yet registered if (publisherId && !(await this.hasElopage(context))) { - loginUser.publisherId = publisherId + userEntity.publisherId = publisherId } const queryRunner = getConnection().createQueryRunner() @@ -760,10 +670,6 @@ export class UserResolver { }) } - await queryRunner.manager.save(loginUser).catch((error) => { - throw new Error('error saving loginUser: ' + error) - }) - await queryRunner.manager.save(userEntity).catch((error) => { throw new Error('error saving user: ' + error) }) @@ -793,7 +699,7 @@ export class UserResolver { throw new Error(`Username must be at minimum ${MIN_CHARACTERS_USERNAME} characters long.`) } - const usersFound = await LoginUser.count({ username }) + const usersFound = await DbUser.count({ username }) // Username already present? if (usersFound !== 0) { diff --git a/backend/src/typeorm/repository/LoginUser.ts b/backend/src/typeorm/repository/LoginUser.ts deleted file mode 100644 index efe6f5428..000000000 --- a/backend/src/typeorm/repository/LoginUser.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { EntityRepository, Repository } from '@dbTools/typeorm' -import { LoginUser } from '@entity/LoginUser' - -@EntityRepository(LoginUser) -export class LoginUserRepository extends Repository { - async findByEmail(email: string): Promise { - return this.createQueryBuilder('loginUser') - .where('loginUser.email = :email', { email }) - .getOneOrFail() - } - - async findBySearchCriteria(searchCriteria: string): Promise { - 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() - } -} diff --git a/backend/src/typeorm/repository/LoginUserBackup.ts b/backend/src/typeorm/repository/LoginUserBackup.ts deleted file mode 100644 index a54b1e8af..000000000 --- a/backend/src/typeorm/repository/LoginUserBackup.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { EntityRepository, Repository } from '@dbTools/typeorm' -import { LoginUserBackup } from '@entity/LoginUserBackup' - -@EntityRepository(LoginUserBackup) -export class LoginUserBackupRepository extends Repository {} diff --git a/backend/src/typeorm/repository/User.ts b/backend/src/typeorm/repository/User.ts index fa7115429..59d6ff465 100644 --- a/backend/src/typeorm/repository/User.ts +++ b/backend/src/typeorm/repository/User.ts @@ -5,7 +5,7 @@ import { User } from '@entity/User' export class UserRepository extends Repository { async findByPubkeyHex(pubkeyHex: string): Promise { return this.createQueryBuilder('user') - .where('hex(user.pubkey) = :pubkeyHex', { pubkeyHex }) + .where('hex(user.pubKey) = :pubkeyHex', { pubkeyHex }) .getOneOrFail() } diff --git a/backend/src/webhook/elopage.ts b/backend/src/webhook/elopage.ts index 9c1aadd2e..0b392abb1 100644 --- a/backend/src/webhook/elopage.ts +++ b/backend/src/webhook/elopage.ts @@ -31,7 +31,7 @@ import { LoginElopageBuys } from '@entity/LoginElopageBuys' import { getCustomRepository } from '@dbTools/typeorm' import { UserResolver } from '../graphql/resolver/UserResolver' import { LoginElopageBuysRepository } from '../typeorm/repository/LoginElopageBuys' -import { LoginUserRepository } from '../typeorm/repository/LoginUser' +import { User as dbUser } from '@entity/User' export const elopageWebhook = async (req: any, res: any): Promise => { // eslint-disable-next-line no-console @@ -114,8 +114,7 @@ export const elopageWebhook = async (req: any, res: any): Promise => { } // Do we already have such a user? - const loginUserRepository = await getCustomRepository(LoginUserRepository) - if ((await loginUserRepository.count({ email })) !== 0) { + if ((await dbUser.count({ email })) !== 0) { // eslint-disable-next-line no-console console.log(`Did not create User - already exists with email: ${email}`) return diff --git a/database/entity/0001-init_db/User.ts b/database/entity/0001-init_db/User.ts index b349e2584..be2c4c5ad 100644 --- a/database/entity/0001-init_db/User.ts +++ b/database/entity/0001-init_db/User.ts @@ -7,13 +7,13 @@ export class User extends BaseEntity { @PrimaryGeneratedColumn('increment', { unsigned: true }) id: number - @Column({ name: 'index_id', default: 0 }) + @Column({ name: 'index_id', type: 'smallint', default: 0, nullable: false }) indexId: number @Column({ name: 'group_id', default: 0, unsigned: true }) groupId: number - @Column({ type: 'binary', length: 32, name: 'public_key' }) + @Column({ name: 'public_key', type: 'binary', length: 32, default: null, nullable: true }) pubkey: Buffer @Column({ length: 255, nullable: true, default: null, collation: 'utf8mb4_unicode_ci' }) @@ -40,7 +40,7 @@ export class User extends BaseEntity { @Column({ length: 255, nullable: true, default: null, collation: 'utf8mb4_unicode_ci' }) username: string - @Column() + @Column({ type: 'bool', default: false }) disabled: boolean @OneToOne(() => Balance, (balance) => balance.user) diff --git a/database/entity/0002-add_settings/User.ts b/database/entity/0002-add_settings/User.ts index 40f5d400a..a756cbbd5 100644 --- a/database/entity/0002-add_settings/User.ts +++ b/database/entity/0002-add_settings/User.ts @@ -7,13 +7,13 @@ export class User extends BaseEntity { @PrimaryGeneratedColumn('increment', { unsigned: true }) id: number - @Column({ name: 'index_id', default: 0 }) + @Column({ name: 'index_id', type: 'smallint', default: 0, nullable: false }) indexId: number @Column({ name: 'group_id', default: 0, unsigned: true }) groupId: number - @Column({ type: 'binary', length: 32, name: 'public_key' }) + @Column({ name: 'public_key', type: 'binary', length: 32, default: null, nullable: true }) pubkey: Buffer @Column({ length: 255, nullable: true, default: null, collation: 'utf8mb4_unicode_ci' }) @@ -40,7 +40,7 @@ export class User extends BaseEntity { @Column({ length: 255, nullable: true, default: null, collation: 'utf8mb4_unicode_ci' }) username: string - @Column() + @Column({ type: 'bool', default: false }) disabled: boolean @OneToMany(() => UserSetting, (userSetting) => userSetting.user) diff --git a/database/entity/0003-login_server_tables/LoginUser.ts b/database/entity/0003-login_server_tables/LoginUser.ts index 07816254f..a3a83f450 100644 --- a/database/entity/0003-login_server_tables/LoginUser.ts +++ b/database/entity/0003-login_server_tables/LoginUser.ts @@ -1,5 +1,5 @@ import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, OneToOne } from 'typeorm' -import { LoginUserBackup } from '../LoginUserBackup' +import { LoginUserBackup } from './LoginUserBackup' // Moriz: I do not like the idea of having two user tables @Entity('login_users') @@ -19,7 +19,7 @@ export class LoginUser extends BaseEntity { @Column({ length: 255, default: '' }) username: string - @Column({ default: '', nullable: true }) + @Column({ type: 'mediumtext', default: '', nullable: true }) description: string @Column({ type: 'bigint', default: 0, unsigned: true }) @@ -34,19 +34,19 @@ export class LoginUser extends BaseEntity { @Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true }) emailHash: Buffer - @Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP' }) + @Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP', nullable: false }) createdAt: Date - @Column({ name: 'email_checked', default: 0 }) + @Column({ name: 'email_checked', type: 'bool', nullable: false, default: false }) emailChecked: boolean - @Column({ name: 'passphrase_shown', default: 0 }) + @Column({ name: 'passphrase_shown', type: 'bool', nullable: false, default: false }) passphraseShown: boolean - @Column({ length: 4, default: 'de' }) + @Column({ length: 4, default: 'de', nullable: false }) language: string - @Column({ default: 0 }) + @Column({ type: 'bool', default: false }) disabled: boolean @Column({ name: 'group_id', default: 0, unsigned: true }) diff --git a/database/entity/0003-login_server_tables/LoginUserBackup.ts b/database/entity/0003-login_server_tables/LoginUserBackup.ts index 7152e12e5..39f5e0db5 100644 --- a/database/entity/0003-login_server_tables/LoginUserBackup.ts +++ b/database/entity/0003-login_server_tables/LoginUserBackup.ts @@ -1,5 +1,5 @@ import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, JoinColumn, OneToOne } from 'typeorm' -import { LoginUser } from '../LoginUser' +import { LoginUser } from './LoginUser' @Entity('login_user_backups') export class LoginUserBackup extends BaseEntity { diff --git a/database/entity/0006-login_users_collation/LoginUser.ts b/database/entity/0006-login_users_collation/LoginUser.ts index e12c82e27..fdb17f4ad 100644 --- a/database/entity/0006-login_users_collation/LoginUser.ts +++ b/database/entity/0006-login_users_collation/LoginUser.ts @@ -1,5 +1,5 @@ import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, OneToOne } from 'typeorm' -import { LoginUserBackup } from '../LoginUserBackup' +import { LoginUserBackup } from '../0003-login_server_tables/LoginUserBackup' // Moriz: I do not like the idea of having two user tables @Entity('login_users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' }) @@ -19,7 +19,7 @@ export class LoginUser extends BaseEntity { @Column({ length: 255, default: '', collation: 'utf8mb4_unicode_ci' }) username: string - @Column({ default: '', collation: 'utf8mb4_unicode_ci', nullable: true }) + @Column({ type: 'mediumtext', default: '', collation: 'utf8mb4_unicode_ci', nullable: true }) description: string @Column({ type: 'bigint', default: 0, unsigned: true }) @@ -34,19 +34,19 @@ export class LoginUser extends BaseEntity { @Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true }) emailHash: Buffer - @Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP' }) + @Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP', nullable: false }) createdAt: Date - @Column({ name: 'email_checked', default: 0 }) + @Column({ name: 'email_checked', type: 'bool', nullable: false, default: false }) emailChecked: boolean - @Column({ name: 'passphrase_shown', default: 0 }) + @Column({ name: 'passphrase_shown', type: 'bool', nullable: false, default: false }) passphraseShown: boolean - @Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci' }) + @Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false }) language: string - @Column({ default: 0 }) + @Column({ type: 'bool', default: false }) disabled: boolean @Column({ name: 'group_id', default: 0, unsigned: true }) diff --git a/database/entity/0017-combine_user_tables/LoginUserBackup.ts b/database/entity/0017-combine_user_tables/LoginUserBackup.ts new file mode 100644 index 000000000..7aa69a021 --- /dev/null +++ b/database/entity/0017-combine_user_tables/LoginUserBackup.ts @@ -0,0 +1,16 @@ +import { BaseEntity, Entity, PrimaryGeneratedColumn, Column } from 'typeorm' + +@Entity('login_user_backups') +export class LoginUserBackup extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Column({ type: 'text', name: 'passphrase', nullable: false }) + passphrase: string + + @Column({ name: 'user_id', nullable: false }) + userId: number + + @Column({ name: 'mnemonic_type', default: -1 }) + mnemonicType: number +} diff --git a/database/entity/0017-combine_user_tables/User.ts b/database/entity/0017-combine_user_tables/User.ts new file mode 100644 index 000000000..a9bf29d24 --- /dev/null +++ b/database/entity/0017-combine_user_tables/User.ts @@ -0,0 +1,74 @@ +import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm' +import { UserSetting } from '../UserSetting' + +@Entity('state_users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' }) +export class User extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Column({ name: 'login_user_id', default: null, unsigned: true }) + loginUserId: number + + @Column({ name: 'index_id', type: 'smallint', default: 0, nullable: false }) + indexId: number + + @Column({ name: 'public_key', type: 'binary', length: 32, default: null, nullable: true }) + pubKey: Buffer + + @Column({ name: 'privkey', type: 'binary', length: 80, default: null, nullable: true }) + privKey: Buffer + + @Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' }) + email: string + + @Column({ + name: 'first_name', + length: 255, + nullable: true, + default: null, + collation: 'utf8mb4_unicode_ci', + }) + firstName: string + + @Column({ + name: 'last_name', + length: 255, + nullable: true, + default: null, + collation: 'utf8mb4_unicode_ci', + }) + lastName: string + + @Column({ length: 255, nullable: true, default: null, collation: 'utf8mb4_unicode_ci' }) + username: string + + @Column({ type: 'bool', default: false }) + disabled: boolean + + @Column({ type: 'mediumtext', default: '', collation: 'utf8mb4_unicode_ci', nullable: true }) + description: string + + @Column({ type: 'bigint', default: 0, unsigned: true }) + password: BigInt + + @Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true }) + emailHash: Buffer + + @Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP', nullable: false }) + createdAt: Date + + @Column({ name: 'email_checked', type: 'bool', nullable: false, default: false }) + emailChecked: boolean + + @Column({ name: 'passphrase_shown', type: 'bool', nullable: false, default: false }) + passphraseShown: boolean + + @Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false }) + language: string + + @Column({ name: 'publisher_id', default: 0 }) + publisherId: number + + @OneToMany(() => UserSetting, (userSetting) => userSetting.user) + settings: UserSetting[] +} diff --git a/database/entity/0018-combine_login_user_backups_and_user_table/User.ts b/database/entity/0018-combine_login_user_backups_and_user_table/User.ts new file mode 100644 index 000000000..2ae351e47 --- /dev/null +++ b/database/entity/0018-combine_login_user_backups_and_user_table/User.ts @@ -0,0 +1,83 @@ +import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm' +import { UserSetting } from '../UserSetting' + +@Entity('state_users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' }) +export class User extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Column({ name: 'login_user_id', default: null, unsigned: true }) + loginUserId: number + + @Column({ name: 'index_id', type: 'smallint', default: 0, nullable: false }) + indexId: number + + @Column({ name: 'public_key', type: 'binary', length: 32, default: null, nullable: true }) + pubKey: Buffer + + @Column({ name: 'privkey', type: 'binary', length: 80, default: null, nullable: true }) + privKey: Buffer + + @Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' }) + email: string + + @Column({ + name: 'first_name', + length: 255, + nullable: true, + default: null, + collation: 'utf8mb4_unicode_ci', + }) + firstName: string + + @Column({ + name: 'last_name', + length: 255, + nullable: true, + default: null, + collation: 'utf8mb4_unicode_ci', + }) + lastName: string + + @Column({ length: 255, nullable: true, default: null, collation: 'utf8mb4_unicode_ci' }) + username: string + + @Column({ type: 'bool', default: false }) + disabled: boolean + + @Column({ type: 'mediumtext', default: '', collation: 'utf8mb4_unicode_ci', nullable: true }) + description: string + + @Column({ type: 'bigint', default: 0, unsigned: true }) + password: BigInt + + @Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true }) + emailHash: Buffer + + @Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP', nullable: false }) + createdAt: Date + + @Column({ name: 'email_checked', type: 'bool', nullable: false, default: false }) + emailChecked: boolean + + @Column({ name: 'passphrase_shown', type: 'bool', nullable: false, default: false }) + passphraseShown: boolean + + @Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false }) + language: string + + @Column({ name: 'publisher_id', default: 0 }) + publisherId: number + + @Column({ + type: 'text', + name: 'passphrase', + collation: 'utf8mb4_unicode_ci', + nullable: true, + default: null, + }) + passphrase: string + + @OneToMany(() => UserSetting, (userSetting) => userSetting.user) + settings: UserSetting[] +} diff --git a/database/entity/0019-replace_login_user_id_with_state_user_id/User.ts b/database/entity/0019-replace_login_user_id_with_state_user_id/User.ts new file mode 100644 index 000000000..b469a55a7 --- /dev/null +++ b/database/entity/0019-replace_login_user_id_with_state_user_id/User.ts @@ -0,0 +1,80 @@ +import { BaseEntity, Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm' +import { UserSetting } from '../UserSetting' + +@Entity('state_users', { engine: 'InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' }) +export class User extends BaseEntity { + @PrimaryGeneratedColumn('increment', { unsigned: true }) + id: number + + @Column({ name: 'index_id', type: 'smallint', default: 0, nullable: false }) + indexId: number + + @Column({ name: 'public_key', type: 'binary', length: 32, default: null, nullable: true }) + pubKey: Buffer + + @Column({ name: 'privkey', type: 'binary', length: 80, default: null, nullable: true }) + privKey: Buffer + + @Column({ length: 255, unique: true, nullable: false, collation: 'utf8mb4_unicode_ci' }) + email: string + + @Column({ + name: 'first_name', + length: 255, + nullable: true, + default: null, + collation: 'utf8mb4_unicode_ci', + }) + firstName: string + + @Column({ + name: 'last_name', + length: 255, + nullable: true, + default: null, + collation: 'utf8mb4_unicode_ci', + }) + lastName: string + + @Column({ length: 255, nullable: true, default: null, collation: 'utf8mb4_unicode_ci' }) + username: string + + @Column({ type: 'bool', default: false }) + disabled: boolean + + @Column({ type: 'mediumtext', default: '', collation: 'utf8mb4_unicode_ci', nullable: true }) + description: string + + @Column({ type: 'bigint', default: 0, unsigned: true }) + password: BigInt + + @Column({ name: 'email_hash', type: 'binary', length: 32, default: null, nullable: true }) + emailHash: Buffer + + @Column({ name: 'created', default: () => 'CURRENT_TIMESTAMP', nullable: false }) + createdAt: Date + + @Column({ name: 'email_checked', type: 'bool', nullable: false, default: false }) + emailChecked: boolean + + @Column({ name: 'passphrase_shown', type: 'bool', nullable: false, default: false }) + passphraseShown: boolean + + @Column({ length: 4, default: 'de', collation: 'utf8mb4_unicode_ci', nullable: false }) + language: string + + @Column({ name: 'publisher_id', default: 0 }) + publisherId: number + + @Column({ + type: 'text', + name: 'passphrase', + collation: 'utf8mb4_unicode_ci', + nullable: true, + default: null, + }) + passphrase: string + + @OneToMany(() => UserSetting, (userSetting) => userSetting.user) + settings: UserSetting[] +} diff --git a/database/entity/LoginUser.ts b/database/entity/LoginUser.ts deleted file mode 100644 index b22e1137f..000000000 --- a/database/entity/LoginUser.ts +++ /dev/null @@ -1 +0,0 @@ -export { LoginUser } from './0006-login_users_collation/LoginUser' diff --git a/database/entity/LoginUserBackup.ts b/database/entity/LoginUserBackup.ts deleted file mode 100644 index 23d2c9271..000000000 --- a/database/entity/LoginUserBackup.ts +++ /dev/null @@ -1 +0,0 @@ -export { LoginUserBackup } from './0003-login_server_tables/LoginUserBackup' diff --git a/database/entity/User.ts b/database/entity/User.ts index b20e934f1..6dcdfed68 100644 --- a/database/entity/User.ts +++ b/database/entity/User.ts @@ -1 +1 @@ -export { User } from './0002-add_settings/User' +export { User } from './0019-replace_login_user_id_with_state_user_id/User' diff --git a/database/entity/index.ts b/database/entity/index.ts index cd1dd4e21..37fe6eb55 100644 --- a/database/entity/index.ts +++ b/database/entity/index.ts @@ -1,8 +1,6 @@ import { Balance } from './Balance' import { LoginElopageBuys } from './LoginElopageBuys' import { LoginEmailOptIn } from './LoginEmailOptIn' -import { LoginUser } from './LoginUser' -import { LoginUserBackup } from './LoginUserBackup' import { Migration } from './Migration' import { ServerUser } from './ServerUser' import { Transaction } from './Transaction' @@ -18,8 +16,6 @@ export const entities = [ Balance, LoginElopageBuys, LoginEmailOptIn, - LoginUser, - LoginUserBackup, Migration, ServerUser, Transaction, diff --git a/database/migrations/0017-combine_user_tables.ts b/database/migrations/0017-combine_user_tables.ts new file mode 100644 index 000000000..04be53615 --- /dev/null +++ b/database/migrations/0017-combine_user_tables.ts @@ -0,0 +1,150 @@ +/* MIGRATION TO COMBINE LOGIN_USERS WITH STATE_USERS TABLE + * + * This migration combines the table `login_users` with + * the `state_users` table, where the later is the target. + */ + +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + // Drop column `group_id` since it contains uniform data which is not the same as the uniform data + // on login_users. Since we do not need this data anyway, we sjust throw it away. + await queryFn('ALTER TABLE `state_users` DROP COLUMN `group_id`;') + + // Remove the unique constraint from the pubkey + await queryFn('ALTER TABLE `state_users` DROP INDEX `public_key`;') + + // Allow NULL on the `state_users` pubkey like it is allowed on `login_users` + await queryFn('ALTER TABLE `state_users` MODIFY COLUMN `public_key` binary(32) DEFAULT NULL;') + + // instead use a unique constraint for the email like on `login_users` + // therefore do not allow null on `email` anymore + await queryFn( + 'ALTER TABLE `state_users` MODIFY COLUMN `email` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL;', + ) + await queryFn('ALTER TABLE `state_users` ADD CONSTRAINT `email` UNIQUE KEY (`email`);') + + // Create `login_user_id` column - to store the login_users.id field to not break references. + await queryFn( + 'ALTER TABLE `state_users` ADD COLUMN `login_user_id` int(10) unsigned DEFAULT NULL AFTER `id`;', + ) + + // Create missing data columns for the data stored in `login_users` + await queryFn( + "ALTER TABLE `state_users` ADD COLUMN `description` mediumtext COLLATE utf8mb4_unicode_ci DEFAULT '' AFTER `disabled`;", + ) + await queryFn( + 'ALTER TABLE `state_users` ADD COLUMN `password` bigint(20) unsigned DEFAULT 0 AFTER `description`;', + ) + await queryFn( + 'ALTER TABLE `state_users` ADD COLUMN `privkey` binary(80) DEFAULT NULL AFTER `public_key`;', + ) + await queryFn( + 'ALTER TABLE `state_users` ADD COLUMN `email_hash` binary(32) DEFAULT NULL AFTER `password`;', + ) + await queryFn( + 'ALTER TABLE `state_users` ADD COLUMN `created` datetime NOT NULL DEFAULT current_timestamp() AFTER `email_hash`;', + ) + await queryFn( + 'ALTER TABLE `state_users` ADD COLUMN `email_checked` tinyint(4) NOT NULL DEFAULT 0 AFTER `created`;', + ) + await queryFn( + 'ALTER TABLE `state_users` ADD COLUMN `passphrase_shown` tinyint(4) NOT NULL DEFAULT 0 AFTER `email_checked`;', + ) + await queryFn( + "ALTER TABLE `state_users` ADD COLUMN `language` varchar(4) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'de' AFTER `passphrase_shown`;", + ) + await queryFn( + 'ALTER TABLE `state_users` ADD COLUMN `publisher_id` int(11) DEFAULT 0 AFTER `language`;', + ) + + // Move data from `login_users` to the newly modified `state_users` table. + // The following rules for overwriting data applies: + // email is the matching criteria + // public_key is overwritten by `login_users`.`pubkey` (we have validated the passphrases here) (2 keys differ) + // first_name is more accurate on `state_users` and stays unchanged (1 users with different first_* & last_name) + // last_name is more accurate on `state_users` and stays unchanged (1 users with different first_* & last_name) + // username does not contain any relevant data, either NULL or '' and therefore we do not change anything here + // disabled does not differ, we can omit it + await queryFn(` + UPDATE state_users + LEFT JOIN login_users ON state_users.email = login_users.email + SET state_users.public_key = login_users.pubkey, + state_users.login_user_id = login_users.id, + state_users.description = login_users.description, + state_users.password = login_users.password, + state_users.privkey = login_users.privkey, + state_users.email_hash = login_users.email_hash, + state_users.created = login_users.created, + state_users.email_checked = login_users.email_checked, + state_users.passphrase_shown = login_users.passphrase_shown, + state_users.language = login_users.language, + state_users.publisher_id = login_users.publisher_id + ; + `) + + // Drop `login_users` table + await queryFn('DROP TABLE `login_users`;') +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn(` + CREATE TABLE \`login_users\` ( + \`id\` int(10) unsigned NOT NULL AUTO_INCREMENT, + \`email\` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL, + \`first_name\` varchar(150) COLLATE utf8mb4_unicode_ci NOT NULL, + \`last_name\` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', + \`username\` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT '', + \`description\` mediumtext COLLATE utf8mb4_unicode_ci DEFAULT '', + \`password\` bigint(20) unsigned DEFAULT 0, + \`pubkey\` binary(32) DEFAULT NULL, + \`privkey\` binary(80) DEFAULT NULL, + \`email_hash\` binary(32) DEFAULT NULL, + \`created\` datetime NOT NULL DEFAULT current_timestamp(), + \`email_checked\` tinyint(4) NOT NULL DEFAULT 0, + \`passphrase_shown\` tinyint(4) NOT NULL DEFAULT 0, + \`language\` varchar(4) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'de', + \`disabled\` tinyint(4) DEFAULT 0, + \`group_id\` int(10) unsigned DEFAULT 0, + \`publisher_id\` int(11) DEFAULT 0, + PRIMARY KEY (\`id\`), + UNIQUE KEY \`email\` (\`email\`) + ) ENGINE=InnoDB AUTO_INCREMENT=2363 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + `) + await queryFn(` + INSERT INTO login_users + ( id, email, first_name, last_name, username, + description, password, pubkey, privkey, email_hash, + created, email_checked, passphrase_shown, language, + disabled, group_id, publisher_id ) + ( SELECT login_user_id AS id, email, first_name, + last_name, username, description, password, + public_key AS pubkey, privkey, email_hash, + created, email_checked, passphrase_shown, + language, disabled, '1' AS group_id, + publisher_id + FROM state_users ) + ; + `) + await queryFn('ALTER TABLE `state_users` DROP COLUMN `publisher_id`;') + await queryFn('ALTER TABLE `state_users` DROP COLUMN `language`;') + await queryFn('ALTER TABLE `state_users` DROP COLUMN `passphrase_shown`;') + await queryFn('ALTER TABLE `state_users` DROP COLUMN `email_checked`;') + await queryFn('ALTER TABLE `state_users` DROP COLUMN `created`;') + await queryFn('ALTER TABLE `state_users` DROP COLUMN `email_hash`;') + await queryFn('ALTER TABLE `state_users` DROP COLUMN `privkey`;') + await queryFn('ALTER TABLE `state_users` DROP COLUMN `password`;') + await queryFn('ALTER TABLE `state_users` DROP COLUMN `description`;') + await queryFn('ALTER TABLE `state_users` DROP COLUMN `login_user_id`;') + await queryFn('ALTER TABLE `state_users` DROP INDEX `email`;') + await queryFn( + 'ALTER TABLE `state_users` MODIFY COLUMN `email` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL;', + ) + // Note: if the public_key is NULL, we need to set a random key in order to meet the constraint + await queryFn( + 'UPDATE `state_users` SET public_key = UNHEX(SHA1(RAND())) WHERE public_key IS NULL;', + ) + await queryFn('ALTER TABLE `state_users` MODIFY COLUMN `public_key` binary(32) NOT NULL;') + await queryFn('ALTER TABLE `state_users` ADD CONSTRAINT `public_key` UNIQUE KEY (`public_key`);') + await queryFn( + 'ALTER TABLE `state_users` ADD COLUMN `group_id` int(10) unsigned NOT NULL DEFAULT 0 AFTER index_id;', + ) +} diff --git a/database/migrations/0018-combine_login_user_backups_and_user_table.ts b/database/migrations/0018-combine_login_user_backups_and_user_table.ts new file mode 100644 index 000000000..2141017bd --- /dev/null +++ b/database/migrations/0018-combine_login_user_backups_and_user_table.ts @@ -0,0 +1,48 @@ +/* MIGRATION TO COMBINE LOGIN_BACKUP_USERS TABLE WITH STATE_USERS + * + * This migration combines the table `login_user_backups` into + * the `state_users` table, where the later is the target. + */ + +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + // We only keep the passphrase, the mnemonic type is a constant, + // since every passphrase was converted to mnemonic type 2 + await queryFn( + 'ALTER TABLE `state_users` ADD COLUMN `passphrase` text DEFAULT NULL AFTER `publisher_id`;', + ) + + // Move data from `login_user_backups` to the newly modified `state_users` table. + await queryFn(` + UPDATE state_users + LEFT JOIN login_user_backups ON state_users.login_user_id = login_user_backups.user_id + SET state_users.passphrase = login_user_backups.passphrase + WHERE login_user_backups.passphrase IS NOT NULL + ; + `) + + // Drop `login_user_backups` table + await queryFn('DROP TABLE `login_user_backups`;') +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn(` + CREATE TABLE \`login_user_backups\` ( + \`id\` int(10) unsigned NOT NULL AUTO_INCREMENT, + \`user_id\` int(11) NOT NULL, + \`passphrase\` text NOT NULL, + \`mnemonic_type\` int(11) DEFAULT -1, + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB AUTO_INCREMENT=1862 DEFAULT CHARSET=utf8mb4; + `) + await queryFn(` + INSERT INTO login_user_backups + ( user_id, passphrase, mnemonic_type ) + ( SELECT login_user_id AS user_id, + passphrase, + '2' as mnemonic_type + FROM state_users + WHERE passphrase IS NOT NULL ) + ; + `) + await queryFn('ALTER TABLE `state_users` DROP COLUMN `passphrase`;') +} diff --git a/database/migrations/0019-replace_login_user_id_with_state_user_id.ts b/database/migrations/0019-replace_login_user_id_with_state_user_id.ts new file mode 100644 index 000000000..719c05443 --- /dev/null +++ b/database/migrations/0019-replace_login_user_id_with_state_user_id.ts @@ -0,0 +1,57 @@ +/* MIGRATION TO REPLACE LOGIN_USER_ID WITH STATE_USER_ID + * + * This migration replaces the `login_user_id with` the + * `state_user.id` and removes corresponding columns. + * The table affected is `login_email_opt_in` + */ + +export async function upgrade(queryFn: (query: string, values?: any[]) => Promise>) { + // Delete email opt in codes which can not be linked to an user + await queryFn(` + DELETE FROM \`login_email_opt_in\` + WHERE user_id NOT IN + ( SELECT login_user_id FROM state_users ) + `) + + // Replace user_id in `login_email_opt_in` + await queryFn(` + UPDATE login_email_opt_in + LEFT JOIN state_users ON state_users.login_user_id = login_email_opt_in.user_id + SET login_email_opt_in.user_id = state_users.id; + `) + + // Remove the column `login_user_id` from `state_users` + await queryFn('ALTER TABLE `state_users` DROP COLUMN `login_user_id`;') +} + +export async function downgrade(queryFn: (query: string, values?: any[]) => Promise>) { + await queryFn( + 'ALTER TABLE `state_users` ADD COLUMN `login_user_id` int(10) unsigned DEFAULT NULL AFTER `id`;', + ) + // Instead of generating new `login_user_id`'s we just use the id of state user. + // This way we do not need to alter the `user_id`'s of `login_email_opt_in` table + // at all when migrating down. + // This is possible since there are no old `login_user.id` referenced anymore and + // we can freely choose them + await queryFn('UPDATE `state_users` SET login_user_id = id') + + // Insert back broken data, since we generate new `user_id`'s the old data might be now + // linked to existing accounts. To prevent that all invalid `user_id`'s are now negative. + // This renders them invalid while still keeping the original value + await queryFn(` + INSERT INTO login_email_opt_in + (id, user_id, verification_code, email_opt_in_type_id, created, resend_count, updated) + VALUES + ('38','-41','7544440030630126261','0','2019-11-09 13:58:21','0','2020-07-17 13:58:29'), + ('1262','-1185','2702555860489093775','3','2020-10-17 00:57:29','0','2020-10-17 00:57:29'), + ('1431','-1319','9846213635571107141','3','2020-12-29 00:07:32','0','2020-12-29 00:07:32'), + ('1548','-1185','1009203004512986277','1','2021-01-26 01:07:29','0','2021-01-26 01:07:29'), + ('1549','-1185','2144334450300724903','1','2021-01-26 01:07:32','0','2021-01-26 01:07:32'), + ('1683','-1525','14803676216828342915','3','2021-03-10 08:39:39','0','2021-03-10 08:39:39'), + ('1899','-1663','16616172057370363741','3','2021-04-12 14:49:18','0','2021-04-12 14:49:18'), + ('2168','-1865','13129474130315401087','3','2021-07-08 11:58:54','0','2021-07-08 11:58:54'), + ('2274','-1935','5775135935896874129','3','2021-08-24 11:40:04','0','2021-08-24 11:40:04'), + ('2318','-1967','5713731625139303791','3','2021-09-06 21:38:30','0','2021-09-06 21:38:30'), + ('2762','-2263','6997866521554931275','1','2021-12-25 11:44:30','0','2021-12-25 11:44:30'); + `) +} diff --git a/database/src/factories/login-user-backup.factory.ts b/database/src/factories/login-user-backup.factory.ts deleted file mode 100644 index c4ae18a77..000000000 --- a/database/src/factories/login-user-backup.factory.ts +++ /dev/null @@ -1,18 +0,0 @@ -import Faker from 'faker' -import { define } from 'typeorm-seeding' -import { LoginUserBackup } from '../../entity/LoginUserBackup' -import { LoginUserBackupContext } from '../interface/UserContext' - -define(LoginUserBackup, (faker: typeof Faker, context?: LoginUserBackupContext) => { - if (!context || !context.userId) { - throw new Error('LoginUserBackup: No userId present!') - } - - const userBackup = new LoginUserBackup() - // TODO: Get the real passphrase - userBackup.passphrase = context.passphrase ? context.passphrase : faker.random.words(24) - userBackup.mnemonicType = context.mnemonicType ? context.mnemonicType : 2 - userBackup.userId = context.userId - - return userBackup -}) diff --git a/database/src/factories/login-user.factory.ts b/database/src/factories/login-user.factory.ts deleted file mode 100644 index b3c0312f3..000000000 --- a/database/src/factories/login-user.factory.ts +++ /dev/null @@ -1,30 +0,0 @@ -import Faker from 'faker' -import { define } from 'typeorm-seeding' -import { LoginUser } from '../../entity/LoginUser' -import { randomBytes } from 'crypto' -import { LoginUserContext } from '../interface/UserContext' - -define(LoginUser, (faker: typeof Faker, context?: LoginUserContext) => { - if (!context) context = {} - - const user = new LoginUser() - user.email = context.email ? context.email : faker.internet.email() - user.firstName = context.firstName ? context.firstName : faker.name.firstName() - user.lastName = context.lastName ? context.lastName : faker.name.lastName() - user.username = context.username ? context.username : faker.internet.userName() - user.description = context.description ? context.description : faker.random.words(4) - // TODO Create real password and keys/hash - user.password = context.password ? context.password : BigInt(0) - user.pubKey = context.pubKey ? context.pubKey : randomBytes(32) - user.privKey = context.privKey ? context.privKey : randomBytes(80) - user.emailHash = context.emailHash ? context.emailHash : randomBytes(32) - user.createdAt = context.createdAt ? context.createdAt : faker.date.recent() - user.emailChecked = context.emailChecked === undefined ? false : context.emailChecked - user.passphraseShown = context.passphraseShown ? context.passphraseShown : false - user.language = context.language ? context.language : 'en' - user.disabled = context.disabled ? context.disabled : false - user.groupId = context.groupId ? context.groupId : 1 - user.publisherId = context.publisherId ? context.publisherId : 0 - - return user -}) diff --git a/database/src/factories/user.factory.ts b/database/src/factories/user.factory.ts index 1f684f23f..966e5ffc8 100644 --- a/database/src/factories/user.factory.ts +++ b/database/src/factories/user.factory.ts @@ -1,21 +1,31 @@ import Faker from 'faker' import { define } from 'typeorm-seeding' import { User } from '../../entity/User' -import { randomBytes } from 'crypto' +import { randomBytes, randomInt } from 'crypto' import { UserContext } from '../interface/UserContext' define(User, (faker: typeof Faker, context?: UserContext) => { if (!context) context = {} const user = new User() - user.pubkey = context.pubkey ? context.pubkey : randomBytes(32) + user.pubKey = context.pubKey ? context.pubKey : randomBytes(32) user.email = context.email ? context.email : faker.internet.email() user.firstName = context.firstName ? context.firstName : faker.name.firstName() user.lastName = context.lastName ? context.lastName : faker.name.lastName() user.username = context.username ? context.username : faker.internet.userName() user.disabled = context.disabled ? context.disabled : false - user.groupId = 0 user.indexId = 0 + user.description = context.description ? context.description : faker.random.words(4) + // TODO Create real password and keys/hash + user.password = context.password ? context.password : BigInt(0) + user.privKey = context.privKey ? context.privKey : randomBytes(80) + user.emailHash = context.emailHash ? context.emailHash : randomBytes(32) + user.createdAt = context.createdAt ? context.createdAt : faker.date.recent() + user.emailChecked = context.emailChecked === undefined ? false : context.emailChecked + user.passphraseShown = context.passphraseShown ? context.passphraseShown : false + user.language = context.language ? context.language : 'en' + user.publisherId = context.publisherId ? context.publisherId : 0 + user.passphrase = context.passphrase ? context.passphrase : faker.random.words(24) return user }) diff --git a/database/src/interface/UserContext.ts b/database/src/interface/UserContext.ts index eb4323aee..0fa1a61b5 100644 --- a/database/src/interface/UserContext.ts +++ b/database/src/interface/UserContext.ts @@ -1,35 +1,20 @@ export interface UserContext { - pubkey?: Buffer + pubKey?: Buffer email?: string firstName?: string lastName?: string username?: string disabled?: boolean -} - -export interface LoginUserContext { - email?: string - firstName?: string - lastName?: string - username?: string description?: string password?: BigInt - pubKey?: Buffer privKey?: Buffer emailHash?: Buffer createdAt?: Date emailChecked?: boolean passphraseShown?: boolean language?: string - disabled?: boolean - groupId?: number publisherId?: number -} - -export interface LoginUserBackupContext { - userId?: number passphrase?: string - mnemonicType?: number } export interface ServerUserContext { @@ -42,8 +27,3 @@ export interface ServerUserContext { created?: Date modified?: Date } - -export interface LoginUserRolesContext { - userId?: number - roleId?: number -} diff --git a/database/src/interface/UserInterface.ts b/database/src/interface/UserInterface.ts index 63804af6b..2e20b857f 100644 --- a/database/src/interface/UserInterface.ts +++ b/database/src/interface/UserInterface.ts @@ -1,5 +1,5 @@ export interface UserInterface { - // from login user (contains state user) + // from user email?: string firstName?: string lastName?: string @@ -16,9 +16,7 @@ export interface UserInterface { disabled?: boolean groupId?: number publisherId?: number - // from login user backup passphrase?: string - mnemonicType?: number // from server user serverUserPassword?: string role?: string diff --git a/database/src/seeds/helpers/user-helpers.ts b/database/src/seeds/helpers/user-helpers.ts index f205ccb00..55ab40e9d 100644 --- a/database/src/seeds/helpers/user-helpers.ts +++ b/database/src/seeds/helpers/user-helpers.ts @@ -1,10 +1,4 @@ -import { - UserContext, - LoginUserContext, - LoginUserBackupContext, - ServerUserContext, - LoginUserRolesContext, -} from '../../interface/UserContext' +import { UserContext, ServerUserContext } from '../../interface/UserContext' import { BalanceContext, TransactionContext, @@ -13,8 +7,6 @@ import { } from '../../interface/TransactionContext' import { UserInterface } from '../../interface/UserInterface' import { User } from '../../../entity/User' -import { LoginUser } from '../../../entity/LoginUser' -import { LoginUserBackup } from '../../../entity/LoginUserBackup' import { ServerUser } from '../../../entity/ServerUser' import { Balance } from '../../../entity/Balance' import { Transaction } from '../../../entity/Transaction' @@ -24,9 +16,6 @@ import { Factory } from 'typeorm-seeding' export const userSeeder = async (factory: Factory, userData: UserInterface): Promise => { const user = await factory(User)(createUserContext(userData)).create() - if (!userData.email) userData.email = user.email - const loginUser = await factory(LoginUser)(createLoginUserContext(userData)).create() - await factory(LoginUserBackup)(createLoginUserBackupContext(userData, loginUser)).create() if (userData.isAdmin) { await factory(ServerUser)(createServerUserContext(userData)).create() @@ -49,47 +38,24 @@ export const userSeeder = async (factory: Factory, userData: UserInterface): Pro const createUserContext = (context: UserInterface): UserContext => { return { - pubkey: context.pubKey, + pubKey: context.pubKey, email: context.email, firstName: context.firstName, lastName: context.lastName, username: context.username, disabled: context.disabled, - } -} - -const createLoginUserContext = (context: UserInterface): LoginUserContext => { - return { - email: context.email, - firstName: context.firstName, - lastName: context.lastName, - username: context.username, description: context.description, password: context.password, - pubKey: context.pubKey, privKey: context.privKey, emailHash: context.emailHash, createdAt: context.createdAt, emailChecked: context.emailChecked, passphraseShown: context.passphraseShown, language: context.language, - disabled: context.disabled, - groupId: context.groupId, publisherId: context.publisherId, } } -const createLoginUserBackupContext = ( - context: UserInterface, - loginUser: LoginUser, -): LoginUserBackupContext => { - return { - passphrase: context.passphrase, - mnemonicType: context.mnemonicType, - userId: loginUser.id, - } -} - const createServerUserContext = (context: UserInterface): ServerUserContext => { return { role: context.role, @@ -103,13 +69,6 @@ const createServerUserContext = (context: UserInterface): ServerUserContext => { } } -const createLoginUserRolesContext = (loginUser: LoginUser): LoginUserRolesContext => { - return { - userId: loginUser.id, - roleId: 1, - } -} - const createBalanceContext = (context: UserInterface, user: User): BalanceContext => { return { modified: context.balanceModified, diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index d92f42f5d..fbd9c8e91 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -69,6 +69,7 @@ "memo": "Nachricht", "message": "Nachricht", "new_balance": "Neuer Kontostand nach Bestätigung", + "no_gdd_available": "Du hast keine GDD zum versenden.", "password": "Passwort", "passwordRepeat": "Passwort wiederholen", "password_new": "Neues Passwort", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 014d449a0..534aa25e8 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -69,6 +69,7 @@ "memo": "Message", "message": "Message", "new_balance": "Account balance after confirmation", + "no_gdd_available": "You do not have GDD to send.", "password": "Password", "passwordRepeat": "Repeat password", "password_new": "New password", diff --git a/frontend/src/views/Pages/SendOverview/GddSend/TransactionForm.spec.js b/frontend/src/views/Pages/SendOverview/GddSend/TransactionForm.spec.js index 463613449..25683e6df 100644 --- a/frontend/src/views/Pages/SendOverview/GddSend/TransactionForm.spec.js +++ b/frontend/src/views/Pages/SendOverview/GddSend/TransactionForm.spec.js @@ -21,7 +21,7 @@ describe('GddSend', () => { } const propsData = { - balance: 100.0, + balance: 0.0, } const Wrapper = () => { @@ -37,7 +37,44 @@ describe('GddSend', () => { expect(wrapper.find('div.transaction-form').exists()).toBeTruthy() }) + describe('transaction form disable because balance 0,0 GDD', () => { + it('has a disabled input field of type email', () => { + expect(wrapper.find('#input-group-1').find('input').attributes('disabled')).toBe('disabled') + }) + it('has a disabled input field for amount', () => { + expect(wrapper.find('#input-2').find('input').attributes('disabled')).toBe('disabled') + }) + it('has a disabled textarea field ', () => { + expect(wrapper.find('#input-3').find('textarea').attributes('disabled')).toBe('disabled') + }) + it('has a message indicating that there are no GDDs to send ', () => { + expect(wrapper.find('.text-danger').text()).toBe('form.no_gdd_available') + }) + it('has no reset button and no submit button ', () => { + expect(wrapper.find('.test-buttons').exists()).toBeFalsy() + }) + }) + describe('transaction form', () => { + beforeEach(() => { + wrapper.setProps({ balance: 100.0 }) + }) + describe('transaction form show because balance 100,0 GDD', () => { + it('has no warning message ', () => { + expect(wrapper.find('.text-danger').exists()).toBeFalsy() + }) + it('has a reset button', () => { + expect(wrapper.find('.test-buttons').findAll('button').at(0).attributes('type')).toBe( + 'reset', + ) + }) + it('has a submit button', () => { + expect(wrapper.find('.test-buttons').findAll('button').at(1).attributes('type')).toBe( + 'submit', + ) + }) + }) + describe('email field', () => { it('has an input field of type email', () => { expect(wrapper.find('#input-group-1').find('input').attributes('type')).toBe('email') diff --git a/frontend/src/views/Pages/SendOverview/GddSend/TransactionForm.vue b/frontend/src/views/Pages/SendOverview/GddSend/TransactionForm.vue index 364f54ac7..0f5650543 100644 --- a/frontend/src/views/Pages/SendOverview/GddSend/TransactionForm.vue +++ b/frontend/src/views/Pages/SendOverview/GddSend/TransactionForm.vue @@ -41,6 +41,7 @@ placeholder="E-Mail" style="font-size: large" class="pl-3" + :disabled="isBalanceDisabled" > @@ -76,6 +77,7 @@ :placeholder="$n(0.01)" style="font-size: large" class="pl-3" + :disabled="isBalanceDisabled" > @@ -105,6 +107,7 @@ v-model="form.memo" class="pl-3" style="font-size: large" + :disabled="isBalanceDisabled" > @@ -114,7 +117,10 @@ - + + {{ $t('form.no_gdd_available') }} + + {{ $t('form.reset') }} @@ -192,6 +198,11 @@ export default { this.form.email = this.form.email.trim() }, }, + computed: { + isBalanceDisabled() { + return this.balance <= 0 ? 'disabled' : false + }, + }, }