diff --git a/backend/src/auth/INALIENABLE_RIGHTS.ts b/backend/src/auth/INALIENABLE_RIGHTS.ts index adca3640f..25a4fa76d 100644 --- a/backend/src/auth/INALIENABLE_RIGHTS.ts +++ b/backend/src/auth/INALIENABLE_RIGHTS.ts @@ -8,4 +8,5 @@ export const INALIENABLE_RIGHTS = [ RIGHTS.SET_PASSWORD, RIGHTS.QUERY_TRANSACTION_LINK, RIGHTS.QUERY_OPT_IN, + RIGHTS.CHECK_USERNAME, ] diff --git a/backend/src/auth/RIGHTS.ts b/backend/src/auth/RIGHTS.ts index df4eed8a1..b3627ff7a 100644 --- a/backend/src/auth/RIGHTS.ts +++ b/backend/src/auth/RIGHTS.ts @@ -34,6 +34,7 @@ export enum RIGHTS { LIST_ALL_CONTRIBUTION_MESSAGES = 'LIST_ALL_CONTRIBUTION_MESSAGES', OPEN_CREATIONS = 'OPEN_CREATIONS', USER = 'USER', + CHECK_USERNAME = 'CHECK_USERNAME', // Admin SEARCH_USERS = 'SEARCH_USERS', SET_USER_ROLE = 'SET_USER_ROLE', diff --git a/backend/src/graphql/resolver/UserResolver.test.ts b/backend/src/graphql/resolver/UserResolver.test.ts index 7d71d74b1..f46d0a9bc 100644 --- a/backend/src/graphql/resolver/UserResolver.test.ts +++ b/backend/src/graphql/resolver/UserResolver.test.ts @@ -53,6 +53,7 @@ import { searchAdminUsers, searchUsers, user as userQuery, + checkUsername, } from '@/seeds/graphql/queries' import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg' import { bobBaumeister } from '@/seeds/users/bob-baumeister' @@ -2442,6 +2443,34 @@ describe('UserResolver', () => { }) }) }) + + describe('check username', () => { + describe('reserved alias', () => { + it('returns false', async () => { + await expect( + query({ query: checkUsername, variables: { username: 'root' } }), + ).resolves.toMatchObject({ + data: { + checkUsername: false, + }, + errors: undefined, + }) + }) + }) + + describe('valid alias', () => { + it('returns true', async () => { + await expect( + query({ query: checkUsername, variables: { username: 'valid' } }), + ).resolves.toMatchObject({ + data: { + checkUsername: true, + }, + errors: undefined, + }) + }) + }) + }) }) describe('printTimeDuration', () => { diff --git a/backend/src/graphql/resolver/UserResolver.ts b/backend/src/graphql/resolver/UserResolver.ts index 0afbfcc5a..a19d9e7d7 100644 --- a/backend/src/graphql/resolver/UserResolver.ts +++ b/backend/src/graphql/resolver/UserResolver.ts @@ -498,6 +498,17 @@ export class UserResolver { return true } + @Authorized([RIGHTS.CHECK_USERNAME]) + @Query(() => Boolean) + async checkUsername(@Arg('username') username: string): Promise { + try { + await validateAlias(username) + return true + } catch { + return false + } + } + @Authorized([RIGHTS.UPDATE_USER_INFOS]) @Mutation(() => Boolean) async updateUserInfos( diff --git a/backend/src/seeds/graphql/queries.ts b/backend/src/seeds/graphql/queries.ts index bc8fa95e8..ce7efbfc3 100644 --- a/backend/src/seeds/graphql/queries.ts +++ b/backend/src/seeds/graphql/queries.ts @@ -22,6 +22,12 @@ export const queryOptIn = gql` } ` +export const checkUsername = gql` + query ($username: String!) { + checkUsername(username: $username) + } +` + export const transactionsQuery = gql` query ($currentPage: Int = 1, $pageSize: Int = 25, $order: Order = DESC) { transactionList(currentPage: $currentPage, pageSize: $pageSize, order: $order) { diff --git a/frontend/src/components/Inputs/InputPasswordConfirmation.vue b/frontend/src/components/Inputs/InputPasswordConfirmation.vue index 56d58d9ad..900cc0a0a 100644 --- a/frontend/src/components/Inputs/InputPasswordConfirmation.vue +++ b/frontend/src/components/Inputs/InputPasswordConfirmation.vue @@ -8,7 +8,7 @@ containsLowercaseCharacter: true, containsUppercaseCharacter: true, containsNumericCharacter: true, - atLeastEightCharactera: true, + atLeastEightCharacters: true, atLeastOneSpecialCharater: true, noWhitespaceCharacters: true, }" diff --git a/frontend/src/components/Inputs/InputUsername.vue b/frontend/src/components/Inputs/InputUsername.vue new file mode 100644 index 000000000..e2048a781 --- /dev/null +++ b/frontend/src/components/Inputs/InputUsername.vue @@ -0,0 +1,71 @@ + + diff --git a/frontend/src/components/UserSettings/UserName.spec.js b/frontend/src/components/UserSettings/UserName.spec.js new file mode 100644 index 000000000..012ba367a --- /dev/null +++ b/frontend/src/components/UserSettings/UserName.spec.js @@ -0,0 +1,157 @@ +import { mount } from '@vue/test-utils' +import UserName from './UserName' +import flushPromises from 'flush-promises' + +import { toastErrorSpy, toastSuccessSpy } from '@test/testSetup' + +const localVue = global.localVue + +const mockAPIcall = jest.fn() + +const storeCommitMock = jest.fn() + +describe('UserName Form', () => { + let wrapper + + const mocks = { + $t: jest.fn((t) => t), + $store: { + state: { + username: 'peter', + }, + commit: storeCommitMock, + }, + $apollo: { + mutate: mockAPIcall, + }, + } + + const Wrapper = () => { + return mount(UserName, { localVue, mocks }) + } + + describe('mount', () => { + beforeEach(() => { + wrapper = Wrapper() + }) + + it('renders the component', () => { + expect(wrapper.find('div#username_form').exists()).toBeTruthy() + }) + + it('has an edit icon', () => { + expect(wrapper.find('svg.bi-pencil').exists()).toBeTruthy() + }) + + it('renders the username', () => { + expect(wrapper.findAll('div.col').at(2).text()).toBe('peter') + }) + + describe('edit username', () => { + beforeEach(async () => { + await wrapper.find('svg.bi-pencil').trigger('click') + }) + + it('shows an cancel icon', () => { + expect(wrapper.find('svg.bi-x-circle').exists()).toBeTruthy() + }) + + it('closes the input when cancel icon is clicked', async () => { + await wrapper.find('svg.bi-x-circle').trigger('click') + expect(wrapper.find('input').exists()).toBeFalsy() + }) + + it('does not change the username when cancel is clicked', async () => { + await wrapper.find('input').setValue('petra') + await wrapper.find('svg.bi-x-circle').trigger('click') + expect(wrapper.findAll('div.col').at(2).text()).toBe('peter') + }) + + it('has a submit button', () => { + expect(wrapper.find('button[type="submit"]').exists()).toBeTruthy() + }) + + it('does not enable submit button when data is not changed', async () => { + await wrapper.find('form').trigger('keyup') + expect(wrapper.find('button[type="submit"]').attributes('disabled')).toBe('disabled') + }) + + describe('successfull submit', () => { + beforeEach(async () => { + mockAPIcall.mockResolvedValue({ + data: { + updateUserInfos: { + validValues: 3, + }, + }, + }) + jest.clearAllMocks() + await wrapper.find('input').setValue('petra') + await wrapper.find('form').trigger('keyup') + await wrapper.find('button[type="submit"]').trigger('click') + await flushPromises() + }) + + it('calls the API', () => { + expect(mockAPIcall).toBeCalledWith( + expect.objectContaining({ + variables: { + alias: 'petra', + }, + }), + ) + }) + + it('commits username to store', () => { + expect(storeCommitMock).toBeCalledWith('username', 'petra') + }) + + it('toasts a success message', () => { + expect(toastSuccessSpy).toBeCalledWith('settings.username.change-success') + }) + + it('has an edit button again', () => { + expect(wrapper.find('svg.bi-pencil').exists()).toBeTruthy() + }) + }) + + describe('submit results in server error', () => { + beforeEach(async () => { + mockAPIcall.mockRejectedValue({ + message: 'Error', + }) + jest.clearAllMocks() + await wrapper.find('input').setValue('petra') + await wrapper.find('form').trigger('keyup') + await wrapper.find('button[type="submit"]').trigger('click') + await flushPromises() + }) + + it('calls the API', () => { + expect(mockAPIcall).toBeCalledWith( + expect.objectContaining({ + variables: { + alias: 'petra', + }, + }), + ) + }) + + it('toasts an error message', () => { + expect(toastErrorSpy).toBeCalledWith('Error') + }) + }) + }) + + describe('no username in store', () => { + beforeEach(() => { + mocks.$store.state.username = null + wrapper = Wrapper() + }) + + it('displays an information why to enter a username', () => { + expect(wrapper.findAll('div.col').at(2).text()).toBe('settings.username.no-username') + }) + }) + }) +}) diff --git a/frontend/src/components/UserSettings/UserName.vue b/frontend/src/components/UserSettings/UserName.vue new file mode 100644 index 000000000..4fc1b2f3a --- /dev/null +++ b/frontend/src/components/UserSettings/UserName.vue @@ -0,0 +1,130 @@ + + + diff --git a/frontend/src/graphql/mutations.js b/frontend/src/graphql/mutations.js index 802ea1818..8a281aad9 100644 --- a/frontend/src/graphql/mutations.js +++ b/frontend/src/graphql/mutations.js @@ -26,6 +26,7 @@ export const forgotPassword = gql` export const updateUserInfos = gql` mutation( + $alias: String $firstName: String $lastName: String $password: String @@ -35,6 +36,7 @@ export const updateUserInfos = gql` $hideAmountGDT: Boolean ) { updateUserInfos( + alias: $alias firstName: $firstName lastName: $lastName password: $password @@ -145,6 +147,7 @@ export const login = gql` mutation($email: String!, $password: String!, $publisherId: Int) { login(email: $email, password: $password, publisherId: $publisherId) { gradidoID + alias firstName lastName language diff --git a/frontend/src/graphql/queries.js b/frontend/src/graphql/queries.js index a21117ac2..f254b93cc 100644 --- a/frontend/src/graphql/queries.js +++ b/frontend/src/graphql/queries.js @@ -89,6 +89,12 @@ export const queryOptIn = gql` } ` +export const checkUsername = gql` + query($username: String!) { + checkUsername(username: $username) + } +` + export const queryTransactionLink = gql` query($code: String!) { queryTransactionLink(code: $code) { diff --git a/frontend/src/locales/de.json b/frontend/src/locales/de.json index 8f02812ec..a29069104 100644 --- a/frontend/src/locales/de.json +++ b/frontend/src/locales/de.json @@ -167,12 +167,15 @@ "thx": "Danke", "to": "bis", "to1": "an", + "username": "Nutzername", + "username-placeholder": "Gebe einen eindeutigen Nutzernamen ein", "validation": { "gddCreationTime": "Das Feld {_field_} muss eine Zahl zwischen {min} und {max} mit höchstens einer Nachkommastelle sein", "gddSendAmount": "Das Feld {_field_} muss eine Zahl zwischen {min} und {max} mit höchstens zwei Nachkommastellen sein", "is-not": "Du kannst dir selbst keine Gradidos überweisen", - "usernmae-regex": "Der Username muss mit einem Buchstaben beginnen, auf den mindestens zwei alpha-numerische Zeichen folgen müssen.", - "usernmae-unique": "Der Username ist bereits vergeben." + "username-allowed-chars": "Der Nutzername darf nur aus Buchstaben (ohne Umlaute), Zahlen, Binde- oder Unterstrichen bestehen.", + "username-hyphens": "Binde- oder Unterstriche müssen zwischen Buchstaben oder Zahlen stehen.", + "username-unique": "Der Nutzername ist bereits vergeben." }, "your_amount": "Dein Betrag" }, @@ -320,7 +323,12 @@ "subtitle": "Wenn du dein Passwort vergessen hast, kannst du es hier zurücksetzen." }, "showAmountGDD": "Dein GDD Betrag ist sichtbar.", - "showAmountGDT": "Dein GDT Betrag ist sichtbar." + "showAmountGDT": "Dein GDT Betrag ist sichtbar.", + "username": { + "change-success": "Dein Nutzername wurde erfolgreich geändert.", + "change-username": "Nutzername ändern", + "no-username": "Bitte gebe einen Nutzernamen ein. Damit hilfst du anderen Benutzern dich zu finden, ohne deine Email preisgeben zu müssen." + } }, "signin": "Anmelden", "signup": "Registrieren", diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 2973150f9..ad7077e69 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -167,12 +167,15 @@ "thx": "Thank you", "to": "to", "to1": "to", + "username": "Username", + "username-placeholder": "Enter a unique username", "validation": { "gddCreationTime": "The field {_field_} must be a number between {min} and {max} with at most one decimal place.", "gddSendAmount": "The {_field_} field must be a number between {min} and {max} with at most two digits after the decimal point", "is-not": "You cannot send Gradidos to yourself", - "usernmae-regex": "The username must start with a letter, followed by at least two alphanumeric characters.", - "usernmae-unique": "This username is already taken." + "username-allowed-chars": "The username may only contain letters, numbers, hyphens or underscores.", + "username-hyphens": "Hyphens or underscores must be in between letters or numbers.", + "username-unique": "This username is already taken." }, "your_amount": "Your amount" }, @@ -320,7 +323,12 @@ "subtitle": "If you have forgotten your password, you can reset it here." }, "showAmountGDD": "Your GDD amount is visible.", - "showAmountGDT": "Your GDT amount is visible." + "showAmountGDT": "Your GDT amount is visible.", + "username": { + "change-success": "Your username has been changed successfully.", + "change-username": "Change username", + "no-username": "Please enter a username. This helps other users to find you without exposing your email." + } }, "signin": "Sign in", "signup": "Sign up", diff --git a/frontend/src/main.js b/frontend/src/main.js index 4809e490c..f31311ab2 100755 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -27,7 +27,7 @@ const filters = loadFilters(i18n) Vue.filter('amount', filters.amount) Vue.filter('GDD', filters.GDD) -loadAllRules(i18n) +loadAllRules(i18n, apolloProvider.defaultClient) addNavigationGuards(router, store, apolloProvider.defaultClient) diff --git a/frontend/src/pages/Settings.vue b/frontend/src/pages/Settings.vue index 530484d9a..c5ca00f08 100644 --- a/frontend/src/pages/Settings.vue +++ b/frontend/src/pages/Settings.vue @@ -3,6 +3,8 @@
+ +

@@ -13,6 +15,7 @@