Merge branch 'master' into 2473-feature-emails-further-design-of-html-emails

This commit is contained in:
elweyn 2023-05-17 15:18:28 +02:00
commit 6796c1a6b9
28 changed files with 586 additions and 148 deletions

View File

@ -13,7 +13,7 @@
</template>
<script>
import { formatDistanceToNow } from 'date-fns'
import { de, en, fr, es, nl } from 'date-fns/locale'
import { de, enUS as en, fr, es, nl } from 'date-fns/locale'
const locales = { en, de, es, fr, nl }

View File

@ -33,7 +33,7 @@
"graphql": "^15.5.1",
"graphql-request": "5.0.0",
"i18n": "^0.15.1",
"jsonwebtoken": "^8.5.1",
"jose": "^4.14.4",
"lodash.clonedeep": "^4.5.0",
"log4js": "^6.4.6",
"mysql2": "^2.3.0",
@ -52,7 +52,6 @@
"@types/faker": "^5.5.9",
"@types/i18n": "^0.13.4",
"@types/jest": "^27.0.2",
"@types/jsonwebtoken": "^8.5.2",
"@types/lodash.clonedeep": "^4.5.6",
"@types/node": "^16.10.3",
"@types/nodemailer": "^6.4.4",

View File

@ -1,5 +1,5 @@
import { JwtPayload } from 'jsonwebtoken'
import { JWTPayload } from 'jose'
export interface CustomJwtPayload extends JwtPayload {
export interface CustomJwtPayload extends JWTPayload {
gradidoID: string
}

View File

@ -8,4 +8,5 @@ export const INALIENABLE_RIGHTS = [
RIGHTS.SET_PASSWORD,
RIGHTS.QUERY_TRANSACTION_LINK,
RIGHTS.QUERY_OPT_IN,
RIGHTS.CHECK_USERNAME,
]

View File

@ -1,22 +1,33 @@
import { verify, sign } from 'jsonwebtoken'
import { SignJWT, jwtVerify } from 'jose'
import { CONFIG } from '@/config/'
import { LogError } from '@/server/LogError'
import { CustomJwtPayload } from './CustomJwtPayload'
export const decode = (token: string): CustomJwtPayload | null => {
export const decode = async (token: string): Promise<CustomJwtPayload | null> => {
if (!token) throw new LogError('401 Unauthorized')
try {
return verify(token, CONFIG.JWT_SECRET) as CustomJwtPayload
const secret = new TextEncoder().encode(CONFIG.JWT_SECRET)
const { payload } = await jwtVerify(token, secret, {
issuer: 'urn:gradido:issuer',
audience: 'urn:gradido:audience',
})
return payload as CustomJwtPayload
} catch (err) {
return null
}
}
export const encode = (gradidoID: string): string => {
const token = sign({ gradidoID }, CONFIG.JWT_SECRET, {
expiresIn: CONFIG.JWT_EXPIRES_IN,
})
export const encode = async (gradidoID: string): Promise<string> => {
const secret = new TextEncoder().encode(CONFIG.JWT_SECRET)
const token = await new SignJWT({ gradidoID, 'urn:gradido:claim': true })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setIssuer('urn:gradido:issuer')
.setAudience('urn:gradido:audience')
.setExpirationTime(CONFIG.JWT_EXPIRES_IN)
.sign(secret)
return token
}

View File

@ -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',

View File

@ -21,7 +21,7 @@ export const isAuthorized: AuthChecker<Context> = async ({ context }, rights) =>
}
// Decode the token
const decoded = decode(context.token)
const decoded = await decode(context.token)
if (!decoded) {
throw new LogError('403.13 - Client certificate revoked')
}
@ -49,6 +49,6 @@ export const isAuthorized: AuthChecker<Context> = async ({ context }, rights) =>
}
// set new header token
context.setHeaders.push({ key: 'token', value: encode(decoded.gradidoID) })
context.setHeaders.push({ key: 'token', value: await encode(decoded.gradidoID) })
return true
}

View File

@ -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', () => {

View File

@ -186,7 +186,7 @@ export class UserResolver {
context.setHeaders.push({
key: 'token',
value: encode(dbUser.gradidoID),
value: await encode(dbUser.gradidoID),
})
await EVENT_USER_LOGIN(dbUser)
@ -498,6 +498,17 @@ export class UserResolver {
return true
}
@Authorized([RIGHTS.CHECK_USERNAME])
@Query(() => Boolean)
async checkUsername(@Arg('username') username: string): Promise<boolean> {
try {
await validateAlias(username)
return true
} catch {
return false
}
}
@Authorized([RIGHTS.UPDATE_USER_INFOS])
@Mutation(() => Boolean)
async updateUserInfos(

View File

@ -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) {

View File

@ -1059,13 +1059,6 @@
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
"@types/jsonwebtoken@^8.5.2":
version "8.5.5"
resolved "https://registry.yarnpkg.com/@types/jsonwebtoken/-/jsonwebtoken-8.5.5.tgz#da5f2f4baee88f052ef3e4db4c1a0afb46cff22c"
integrity sha512-OGqtHQ7N5/Ap/TUwO6IgHDuLiAoTmHhGpNvgkCm/F4N6pKzx/RBSfr2OXZSwC6vkfnsEdb6+7DNZVtiXiwdwFw==
dependencies:
"@types/node" "*"
"@types/keygrip@*":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@types/keygrip/-/keygrip-1.0.2.tgz#513abfd256d7ad0bf1ee1873606317b33b1b2a72"
@ -2002,11 +1995,6 @@ bser@2.1.1:
dependencies:
node-int64 "^0.4.0"
buffer-equal-constant-time@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819"
integrity sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=
buffer-from@^1.0.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
@ -2699,13 +2687,6 @@ duplexer3@^0.1.4:
resolved "https://registry.yarnpkg.com/duplexer3/-/duplexer3-0.1.4.tgz#ee01dd1cac0ed3cbc7fdbea37dc0a8f1ce002ce2"
integrity sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=
ecdsa-sig-formatter@1.0.11:
version "1.0.11"
resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
dependencies:
safe-buffer "^5.0.1"
ee-first@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
@ -4805,6 +4786,11 @@ jest@^27.2.4:
import-local "^3.0.2"
jest-cli "^27.2.5"
jose@^4.14.4:
version "4.14.4"
resolved "https://registry.yarnpkg.com/jose/-/jose-4.14.4.tgz#59e09204e2670c3164ee24cbfe7115c6f8bff9ca"
integrity sha512-j8GhLiKmUAh+dsFXlX1aJCbt5KMibuKb+d7j1JaOJG6s2UjX1PQlW+OKB/sD4a/5ZYF4RcmYmLSndOoU3Lt/3g==
js-sdsl@^4.1.4:
version "4.3.0"
resolved "https://registry.yarnpkg.com/js-sdsl/-/js-sdsl-4.3.0.tgz#aeefe32a451f7af88425b11fdb5f58c90ae1d711"
@ -4918,22 +4904,6 @@ jsonfile@^6.0.1:
optionalDependencies:
graceful-fs "^4.1.6"
jsonwebtoken@^8.5.1:
version "8.5.1"
resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-8.5.1.tgz#00e71e0b8df54c2121a1f26137df2280673bcc0d"
integrity sha512-XjwVfRS6jTMsqYs0EsuJ4LGxXV14zQybNd4L2r0UvbVnSF9Af8x7p5MzbJ90Ioz/9TI41/hTCvznF/loiSzn8w==
dependencies:
jws "^3.2.2"
lodash.includes "^4.3.0"
lodash.isboolean "^3.0.3"
lodash.isinteger "^4.0.4"
lodash.isnumber "^3.0.3"
lodash.isplainobject "^4.0.6"
lodash.isstring "^4.0.1"
lodash.once "^4.0.0"
ms "^2.1.1"
semver "^5.6.0"
jstransformer@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/jstransformer/-/jstransformer-1.0.0.tgz#ed8bf0921e2f3f1ed4d5c1a44f68709ed24722c3"
@ -4953,23 +4923,6 @@ juice@^8.0.0:
slick "^1.12.2"
web-resource-inliner "^6.0.1"
jwa@^1.4.1:
version "1.4.1"
resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a"
integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==
dependencies:
buffer-equal-constant-time "1.0.1"
ecdsa-sig-formatter "1.0.11"
safe-buffer "^5.0.1"
jws@^3.2.2:
version "3.2.2"
resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304"
integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==
dependencies:
jwa "^1.4.1"
safe-buffer "^5.0.1"
keyv@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/keyv/-/keyv-3.1.0.tgz#ecc228486f69991e49e9476485a5be1e8fc5c4d9"
@ -5073,46 +5026,11 @@ lodash.get@^4.4.2:
resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99"
integrity sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=
lodash.includes@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/lodash.includes/-/lodash.includes-4.3.0.tgz#60bb98a87cb923c68ca1e51325483314849f553f"
integrity sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=
lodash.isboolean@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz#6c2e171db2a257cd96802fd43b01b20d5f5870f6"
integrity sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=
lodash.isinteger@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz#619c0af3d03f8b04c31f5882840b77b11cd68343"
integrity sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=
lodash.isnumber@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz#3ce76810c5928d03352301ac287317f11c0b1ffc"
integrity sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=
lodash.isplainobject@^4.0.6:
version "4.0.6"
resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
lodash.isstring@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451"
integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=
lodash.merge@^4.6.2:
version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
lodash.once@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=
lodash.sortby@^4.7.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438"
@ -6344,7 +6262,7 @@ semver@7.x, semver@^7.3.2, semver@^7.3.4:
dependencies:
lru-cache "^6.0.0"
semver@^5.5.0, semver@^5.6.0, semver@^5.7.1:
semver@^5.5.0, semver@^5.7.1:
version "5.7.1"
resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7"
integrity sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==

View File

@ -3,15 +3,23 @@
<div class="bg-white appBoxShadow gradido-border-radius p-3">
<div class="h3 mb-4">{{ $t('form.send_check') }}</div>
<b-row class="mt-5">
<b-col cols="2"></b-col>
<b-col>
<div class="h4">{{ userName ? userName : identifier }}</div>
<div class="mt-3 h5">{{ $t('form.memo') }}</div>
<div>{{ memo }}</div>
</b-col>
<b-col cols="3">
<div class="small">{{ $t('send_gdd') }}</div>
<div>{{ amount | GDD }}</div>
<b-col cols="12">
<b-row class="mt-3">
<b-col class="h5">{{ $t('form.recipientCommunity') }}</b-col>
<b-col>{{ communityName }}</b-col>
</b-row>
<b-row>
<b-col class="h5">{{ $t('form.recipient') }}</b-col>
<b-col>{{ userName ? userName : identifier }}</b-col>
</b-row>
<b-row>
<b-col class="h5">{{ $t('form.amount') }}</b-col>
<b-col>{{ amount | GDD }}</b-col>
</b-row>
<b-row>
<b-col class="h5">{{ $t('form.memo') }}</b-col>
<b-col>{{ memo }}</b-col>
</b-row>
</b-col>
</b-row>
@ -58,6 +66,8 @@
</div>
</template>
<script>
import { COMMUNITY_NAME } from '@/config'
export default {
name: 'TransactionConfirmationSend',
props: {
@ -70,6 +80,7 @@ export default {
data() {
return {
disabled: false,
communityName: COMMUNITY_NAME,
}
},
}

View File

@ -49,6 +49,14 @@
<b-row>
<b-col>
<b-row>
<b-col class="mb-4" cols="12" v-if="radioSelected === sendTypes.send">
<b-row>
<b-col>{{ $t('form.recipientCommunity') }}</b-col>
</b-row>
<b-row>
<b-col class="font-weight-bold">{{ communityName }}</b-col>
</b-row>
</b-col>
<b-col cols="12" v-if="radioSelected === sendTypes.send">
<div v-if="!gradidoID">
<input-email
@ -131,6 +139,7 @@ import InputAmount from '@/components/Inputs/InputAmount'
import InputTextarea from '@/components/Inputs/InputTextarea'
import { user as userQuery } from '@/graphql/queries'
import { isEmpty } from 'lodash'
import { COMMUNITY_NAME } from '@/config'
export default {
name: 'TransactionForm',
@ -155,6 +164,7 @@ export default {
},
radioSelected: this.selected,
userName: '',
communityName: COMMUNITY_NAME,
}
},
methods: {

View File

@ -8,7 +8,7 @@
containsLowercaseCharacter: true,
containsUppercaseCharacter: true,
containsNumericCharacter: true,
atLeastEightCharactera: true,
atLeastEightCharacters: true,
atLeastOneSpecialCharater: true,
noWhitespaceCharacters: true,
}"

View File

@ -0,0 +1,71 @@
<template>
<validation-provider
tag="div"
:rules="rules"
:name="name"
:bails="!showAllErrors"
:immediate="immediate"
vid="username"
v-slot="{ errors, valid, validated, ariaInput, ariaMsg }"
>
<b-form-group :label-for="labelFor">
<b-form-input
v-model="currentValue"
v-bind="ariaInput"
:id="labelFor"
:name="name"
:placeholder="placeholder"
type="text"
:state="validated ? valid : false"
autocomplete="off"
></b-form-input>
<b-form-invalid-feedback v-bind="ariaMsg">
<div v-if="showAllErrors">
<span v-for="error in errors" :key="error">
{{ error }}
<br />
</span>
</div>
<div v-else>
{{ errors[0] }}
</div>
</b-form-invalid-feedback>
</b-form-group>
</validation-provider>
</template>
<script>
export default {
name: 'InputUsername',
props: {
rules: {
default: () => {
return {
required: true,
}
},
},
name: { type: String, default: 'username' },
label: { type: String, default: 'Username' },
placeholder: { type: String, default: 'Username' },
value: { required: true, type: String },
showAllErrors: { type: Boolean, default: false },
immediate: { type: Boolean, default: false },
unique: { type: Boolean, required: true },
},
data() {
return {
currentValue: this.value,
}
},
computed: {
labelFor() {
return this.name + '-input-field'
},
},
watch: {
currentValue() {
this.$emit('input', this.currentValue)
},
},
}
</script>

View File

@ -12,7 +12,7 @@
</template>
<script>
import { formatDistance } from 'date-fns'
import { en, de, es, fr, nl } from 'date-fns/locale'
import { enUS as en, de, es, fr, nl } from 'date-fns/locale'
const locales = { en, de, es, fr, nl }

View File

@ -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')
})
})
})
})

View File

@ -0,0 +1,130 @@
<template>
<b-card id="username_form" class="card-border-radius card-background-gray">
<div>
<b-row class="mb-4 text-right">
<b-col class="text-right">
<a
class="cursor-pointer"
@click="showUserData ? (showUserData = !showUserData) : cancelEdit()"
>
<span class="pointer mr-3">{{ $t('settings.username.change-username') }}</span>
<b-icon v-if="showUserData" class="pointer ml-3" icon="pencil"></b-icon>
<b-icon v-else icon="x-circle" class="pointer ml-3" variant="danger"></b-icon>
</a>
</b-col>
</b-row>
</div>
<div>
<validation-observer ref="usernameObserver" v-slot="{ handleSubmit, invalid }">
<b-form @submit.stop.prevent="handleSubmit(onSubmit)">
<b-row class="mb-3">
<b-col class="col-12">
<small>
<b>{{ $t('form.username') }}</b>
</small>
</b-col>
<b-col v-if="showUserData" class="col-12">
<span v-if="username">
{{ username }}
</span>
<div v-else class="alert">
{{ $t('settings.username.no-username') }}
</div>
</b-col>
<b-col v-else class="col-12">
<input-username
v-model="username"
:name="$t('form.username')"
:placeholder="$t('form.username-placeholder')"
:showAllErrors="true"
:unique="true"
:rules="rules"
/>
</b-col>
</b-row>
<b-row class="text-right" v-if="!showUserData">
<b-col>
<div class="text-right" ref="submitButton">
<b-button
:variant="disabled(invalid) ? 'light' : 'success'"
@click="onSubmit"
type="submit"
:disabled="disabled(invalid)"
>
{{ $t('form.save') }}
</b-button>
</div>
</b-col>
</b-row>
</b-form>
</validation-observer>
</div>
</b-card>
</template>
<script>
import { updateUserInfos } from '@/graphql/mutations'
import InputUsername from '@/components/Inputs/InputUsername'
export default {
name: 'UserName',
components: {
InputUsername,
},
data() {
return {
showUserData: true,
username: this.$store.state.username || '',
usernameUnique: false,
rules: {
required: true,
min: 3,
max: 20,
usernameAllowedChars: true,
usernameHyphens: true,
usernameUnique: true,
},
}
},
methods: {
cancelEdit() {
this.username = this.$store.state.username || ''
this.showUserData = true
},
async onSubmit(event) {
event.preventDefault()
this.$apollo
.mutate({
mutation: updateUserInfos,
variables: {
alias: this.username,
},
})
.then(() => {
this.$store.commit('username', this.username)
this.showUserData = true
this.toastSuccess(this.$t('settings.username.change-success'))
})
.catch((error) => {
this.toastError(error.message)
})
},
disabled(invalid) {
return !this.newUsername || invalid
},
},
computed: {
newUsername() {
return this.username !== this.$store.state.username
},
},
}
</script>
<style>
.cursor-pointer {
cursor: pointer;
}
div.alert {
color: red;
}
</style>

View File

@ -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

View File

@ -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) {

View File

@ -153,6 +153,7 @@
"password_new_repeat": "Neues Passwort wiederholen",
"password_old": "Altes Passwort",
"recipient": "Empfänger",
"recipientCommunity": "Gemeinschaft des Empfängers",
"reply": "Antworten",
"reset": "Zurücksetzen",
"save": "Speichern",
@ -166,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"
},
@ -319,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",

View File

@ -153,6 +153,7 @@
"password_new_repeat": "Repeat new password",
"password_old": "Old password",
"recipient": "Recipient",
"recipientCommunity": "Community of the recipient",
"reply": "Reply",
"reset": "Reset",
"save": "Save",
@ -166,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"
},
@ -319,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",

View File

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

View File

@ -3,6 +3,8 @@
<user-card :balance="balance" :transactionCount="transactionCount"></user-card>
<user-data />
<hr />
<user-name />
<hr />
<user-password />
<hr />
<user-language />
@ -13,6 +15,7 @@
<script>
import UserCard from '@/components/UserSettings/UserCard'
import UserData from '@/components/UserSettings/UserData'
import UserName from '@/components/UserSettings/UserName'
import UserPassword from '@/components/UserSettings/UserPassword'
import UserLanguage from '@/components/UserSettings/UserLanguage'
import UserNewsletter from '@/components/UserSettings/UserNewsletter'
@ -22,6 +25,7 @@ export default {
components: {
UserCard,
UserData,
UserName,
UserPassword,
UserLanguage,
UserNewsletter,

View File

@ -16,9 +16,9 @@ export const mutations = {
gradidoID: (state, gradidoID) => {
state.gradidoID = gradidoID
},
// username: (state, username) => {
// state.username = username
// },
username: (state, username) => {
state.username = username
},
firstName: (state, firstName) => {
state.firstName = firstName
},
@ -59,7 +59,7 @@ export const actions = {
login: ({ dispatch, commit }, data) => {
commit('gradidoID', data.gradidoID)
commit('language', data.language)
// commit('username', data.username)
commit('username', data.alias)
commit('firstName', data.firstName)
commit('lastName', data.lastName)
commit('newsletterState', data.klickTipp.newsletterState)
@ -71,7 +71,7 @@ export const actions = {
},
logout: ({ commit, state }) => {
commit('token', null)
// commit('username', '')
commit('username', '')
commit('gradidoID', null)
commit('firstName', '')
commit('lastName', '')

View File

@ -26,6 +26,7 @@ const {
token,
firstName,
lastName,
username,
newsletterState,
publisherId,
isAdmin,
@ -104,6 +105,14 @@ describe('Vuex store', () => {
})
})
describe('username', () => {
it('sets the state of username', () => {
const state = { username: null }
username(state, 'peter')
expect(state.username).toEqual('peter')
})
})
describe('newsletterState', () => {
it('sets the state of newsletterState', () => {
const state = { newsletterState: null }
@ -166,6 +175,7 @@ describe('Vuex store', () => {
const commitedData = {
gradidoID: 'my-gradido-id',
language: 'de',
alias: 'peter',
firstName: 'Peter',
lastName: 'Lustig',
klickTipp: {
@ -180,7 +190,7 @@ describe('Vuex store', () => {
it('calls eleven commits', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenCalledTimes(10)
expect(commit).toHaveBeenCalledTimes(11)
})
it('commits gradidoID', () => {
@ -193,44 +203,49 @@ describe('Vuex store', () => {
expect(commit).toHaveBeenNthCalledWith(2, 'language', 'de')
})
it('commits username', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(3, 'username', 'peter')
})
it('commits firstName', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(3, 'firstName', 'Peter')
expect(commit).toHaveBeenNthCalledWith(4, 'firstName', 'Peter')
})
it('commits lastName', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(4, 'lastName', 'Lustig')
expect(commit).toHaveBeenNthCalledWith(5, 'lastName', 'Lustig')
})
it('commits newsletterState', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(5, 'newsletterState', true)
expect(commit).toHaveBeenNthCalledWith(6, 'newsletterState', true)
})
it('commits hasElopage', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(6, 'hasElopage', false)
expect(commit).toHaveBeenNthCalledWith(7, 'hasElopage', false)
})
it('commits publisherId', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(7, 'publisherId', 1234)
expect(commit).toHaveBeenNthCalledWith(8, 'publisherId', 1234)
})
it('commits isAdmin', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(8, 'isAdmin', true)
expect(commit).toHaveBeenNthCalledWith(9, 'isAdmin', true)
})
it('commits hideAmountGDD', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(9, 'hideAmountGDD', false)
expect(commit).toHaveBeenNthCalledWith(10, 'hideAmountGDD', false)
})
it('commits hideAmountGDT', () => {
login({ commit, state }, commitedData)
expect(commit).toHaveBeenNthCalledWith(10, 'hideAmountGDT', true)
expect(commit).toHaveBeenNthCalledWith(11, 'hideAmountGDT', true)
})
})
@ -240,7 +255,7 @@ describe('Vuex store', () => {
it('calls eleven commits', () => {
logout({ commit, state })
expect(commit).toHaveBeenCalledTimes(10)
expect(commit).toHaveBeenCalledTimes(11)
})
it('commits token', () => {
@ -248,49 +263,54 @@ describe('Vuex store', () => {
expect(commit).toHaveBeenNthCalledWith(1, 'token', null)
})
it('commits username', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(2, 'username', '')
})
it('commits gradidoID', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(2, 'gradidoID', null)
expect(commit).toHaveBeenNthCalledWith(3, 'gradidoID', null)
})
it('commits firstName', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(3, 'firstName', '')
expect(commit).toHaveBeenNthCalledWith(4, 'firstName', '')
})
it('commits lastName', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(4, 'lastName', '')
expect(commit).toHaveBeenNthCalledWith(5, 'lastName', '')
})
it('commits newsletterState', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(5, 'newsletterState', null)
expect(commit).toHaveBeenNthCalledWith(6, 'newsletterState', null)
})
it('commits hasElopage', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(6, 'hasElopage', false)
expect(commit).toHaveBeenNthCalledWith(7, 'hasElopage', false)
})
it('commits publisherId', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(7, 'publisherId', null)
expect(commit).toHaveBeenNthCalledWith(8, 'publisherId', null)
})
it('commits isAdmin', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(8, 'isAdmin', false)
expect(commit).toHaveBeenNthCalledWith(9, 'isAdmin', false)
})
it('commits hideAmountGDD', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(9, 'hideAmountGDD', false)
expect(commit).toHaveBeenNthCalledWith(10, 'hideAmountGDD', false)
})
it('commits hideAmountGDT', () => {
logout({ commit, state })
expect(commit).toHaveBeenNthCalledWith(10, 'hideAmountGDT', true)
expect(commit).toHaveBeenNthCalledWith(11, 'hideAmountGDT', true)
})
// how to get this working?
it.skip('calls localStorage.clear()', () => {

View File

@ -1,8 +1,9 @@
import { configure, extend } from 'vee-validate'
// eslint-disable-next-line camelcase
import { required, email, min, max, is_not } from 'vee-validate/dist/rules'
import { checkUsername } from '@/graphql/queries'
export const loadAllRules = (i18nCallback) => {
export const loadAllRules = (i18nCallback, apollo) => {
configure({
defaultMessage: (field, values) => {
// eslint-disable-next-line @intlify/vue-i18n/no-dynamic-keys
@ -96,7 +97,7 @@ export const loadAllRules = (i18nCallback) => {
message: (_, values) => i18nCallback.t('site.signup.one_number', values),
})
extend('atLeastEightCharactera', {
extend('atLeastEightCharacters', {
validate(value) {
return !!value.match(/.{8,}/)
},
@ -123,4 +124,35 @@ export const loadAllRules = (i18nCallback) => {
},
message: (_, values) => i18nCallback.t('site.signup.dont_match', values),
})
extend('usernameAllowedChars', {
validate(value) {
return !!value.match(/^[a-zA-Z0-9_-]+$/)
},
message: (_, values) => i18nCallback.t('form.validation.username-allowed-chars', values),
})
extend('usernameHyphens', {
validate(value) {
return !!value.match(/^[a-zA-Z0-9]+(?:[_-][a-zA-Z0-9]+?)*$/)
},
message: (_, values) => i18nCallback.t('form.validation.username-hyphens', values),
})
extend('usernameUnique', {
validate(value) {
if (!value.match(/^(?=.{3,20}$)[a-zA-Z0-9]+(?:[_-][a-zA-Z0-9]+?)*$/)) return true
return apollo
.query({
query: checkUsername,
variables: { username: value },
})
.then(({ data }) => {
return {
valid: data.checkUsername,
}
})
},
message: (_, values) => i18nCallback.t('form.validation.username-unique', values),
})
}

View File

@ -34,7 +34,7 @@ const i18nMock = {
n: (value, format) => value,
}
loadAllRules(i18nMock)
loadAllRules(i18nMock, { query: jest.fn().mockResolvedValue({ data: { checkUsername: true } }) })
global.localVue = createLocalVue()