Merge branch 'master' into dlt_deferred_transfer

This commit is contained in:
einhornimmond 2025-01-14 16:50:34 +01:00
commit a867a0eba7
75 changed files with 4835 additions and 4804 deletions

View File

@ -5,7 +5,7 @@ on: push
jobs:
end-to-end-tests:
name: End-to-End Tests
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v3
@ -13,19 +13,6 @@ jobs:
- name: Boot up test system | docker-compose mariadb
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach mariadb
- name: Boot up test system | docker-compose database
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps database
- name: Boot up test system | docker-compose backend
run: |
cd backend
cp .env.test_e2e .env
cd ..
docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps backend
- name: Boot up test system | docker-compose frontends
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps frontend admin nginx
- name: Sleep for 10 seconds
run: sleep 10s
@ -37,8 +24,12 @@ jobs:
cd ../backend
yarn && yarn seed
- name: Boot up test system | docker-compose mailserver
run: docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps mailserver
- name: Boot up test system | docker-compose backend, frontend, admin, nginx, mailserver
run: |
cd backend
cp .env.test_e2e .env
cd ..
docker compose -f docker-compose.yml -f docker-compose.test.yml up --detach --no-deps backend frontend admin nginx mailserver
- name: End-to-end tests | prepare
run: |

1
admin/.gitignore vendored
View File

@ -1,6 +1,7 @@
node_modules/
build/
.cache/
.yarn/install-state.gz
/.env
/.env.bak

View File

@ -5,7 +5,6 @@
"author": "Moriz Wahl",
"version": "2.4.1",
"license": "Apache-2.0",
"private": false,
"scripts": {
"start": "node run/server.js",
"dev": "vite",
@ -37,7 +36,7 @@
"babel-preset-env": "^1.7.0",
"babel-preset-vue": "^2.0.2",
"bootstrap": "^5.3.3",
"bootstrap-vue-next": "^0.23.2",
"bootstrap-vue-next": "0.26.8",
"date-fns": "^2.29.3",
"dotenv-webpack": "^7.0.3",
"express": "^4.17.1",
@ -50,7 +49,7 @@
"sass": "^1.77.8",
"vite": "3.2.10",
"vite-plugin-commonjs": "^0.10.1",
"vue": "3.4.31",
"vue": "3.5.13",
"vue-apollo": "3.1.2",
"vue-i18n": "9.13.1",
"vue-router": "4.4.0",

7
admin/prepare-and-build.sh Executable file
View File

@ -0,0 +1,7 @@
# TODO this is the quick&dirty solution for the openssl security topic, please see https://stackoverflow.com/questions/69692842/error-message-error0308010cdigital-envelope-routinesunsupported
$env:NODE_OPTIONS = "--openssl-legacy-provider"
nvm use
yarn cache clean
yarn install
yarn build

View File

@ -1,11 +1,12 @@
// Imports
import CONFIG from '../src/config'
const express = require('express')
const path = require('path')
// Host & Port
const hostname = '127.0.0.1'
const port = import.meta.env.PORT || 8080
const hostname = CONFIG.ADMIN_MODULE_HOST // '127.0.0.1'
const port = CONFIG.ADMIN_MODULE_PORT // process.env.PORT || 8080
// Express Server
const app = express()
// Serve files

View File

@ -125,6 +125,7 @@ import { createContributionLink } from '@/graphql/createContributionLink.js'
import { updateContributionLink } from '@/graphql/updateContributionLink.js'
import { useAppToast } from '@/composables/useToast'
import { useI18n } from 'vue-i18n'
import { useDateFormatter } from '@/composables/useDateFormatter'
const props = defineProps({
contributionLinkData: {
@ -138,6 +139,8 @@ const emit = defineEmits(['get-contribution-links', 'close-contribution-form'])
const { t } = useI18n()
const { formatDateFromDateTime } = useDateFormatter()
const contributionLinkForm = ref(null)
const form = ref({
@ -201,11 +204,6 @@ const onSubmit = async () => {
}
}
const formatDateFromDateTime = (datetimeString) => {
if (!datetimeString || !datetimeString?.includes('T')) return datetimeString
return datetimeString.split('T')[0]
}
const onReset = () => {
form.value = { validFrom: null, validTo: null }
}

View File

@ -8,8 +8,10 @@
</BFormCheckbox>
</BFormGroup>
<BFormGroup v-if="showResubmissionDate">
<BFormInput v-model="resubmissionDate" type="date" :min="now"></BFormInput>
<time-picker v-model="resubmissionTime"></time-picker>
<div class="d-flex my-2">
<BFormInput v-model="resubmissionDate" type="date" :min="now" class="w-25 me-2" />
<time-picker v-model="resubmissionTime" />
</div>
</BFormGroup>
<BTabs v-model="tabindex" content-class="mt-3" data-test="message-type-tabs">
<BTab active>
@ -24,7 +26,7 @@
v-model="form.text"
:placeholder="$t('contributionLink.memo')"
rows="3"
></BFormTextarea>
/>
</BTab>
<BTab>
<template #title>
@ -38,7 +40,7 @@
v-model="form.text"
:placeholder="$t('moderator.notice')"
rows="3"
></BFormTextarea>
/>
</BTab>
<BTab>
<template #title>
@ -52,7 +54,7 @@
v-model="form.memo"
:placeholder="$t('contributionLink.memo')"
rows="3"
></BFormTextarea>
/>
</BTab>
</BTabs>
<BRow class="mt-4 mb-6">
@ -85,6 +87,7 @@ import TimePicker from '@/components/input/TimePicker'
import { adminCreateContributionMessage } from '@/graphql/adminCreateContributionMessage'
import { adminUpdateContribution } from '@/graphql/adminUpdateContribution'
import { useAppToast } from '@/composables/useToast'
import { useDateFormatter } from '@/composables/useDateFormatter'
const props = defineProps({
contributionId: {
@ -115,6 +118,7 @@ const emit = defineEmits([
const { t } = useI18n()
const { toastError, toastSuccess } = useAppToast()
const { formatDateFromDateTime } = useDateFormatter()
const form = ref({
text: '',
@ -125,7 +129,7 @@ const loading = ref(false)
const localInputResubmissionDate = props.inputResubmissionDate
? new Date(props.inputResubmissionDate)
: null
const resubmissionDate = ref(localInputResubmissionDate)
const resubmissionDate = ref(formatDateFromDateTime(props.inputResubmissionDate))
const resubmissionTime = ref(
localInputResubmissionDate
? localInputResubmissionDate.toLocaleTimeString('de-DE', {
@ -141,14 +145,20 @@ const messageType = {
MODERATOR: 'MODERATOR',
}
const disabled = computed(() => {
return (
(tabindex.value === 0 && form.value.text === '') ||
const isTextTabValid = computed(() => form.value.text !== '')
const isMemoTabValid = computed(() => form.value.memo.length >= 5)
const disabled = computed(
() =>
loading.value ||
(tabindex.value === 1 && form.value.memo.length < 5) ||
(showResubmissionDate.value && !resubmissionDate.value)
)
})
(!(showResubmissionDate.value && resubmissionDate.value) &&
([0, 1].includes(tabindex.value)
? !isTextTabValid.value
: tabindex.value === 2
? !isMemoTabValid.value
: false)),
)
const now = computed(() => new Date())

View File

@ -19,8 +19,8 @@ describe('TimePicker', () => {
expect(wrapper.vm.timeValue).toBe('23:45')
// Check if update:modelValue event is emitted with updated value
expect(wrapper.emitted('input')).toBeTruthy()
expect(wrapper.emitted('input')[0]).toEqual(['23:45'])
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['23:45'])
})
it('validates and corrects time format on blur', async () => {
@ -30,8 +30,8 @@ describe('TimePicker', () => {
// Simulate user input
await input.setValue('99:99')
expect(wrapper.emitted('input')).toBeTruthy()
expect(wrapper.emitted('input')[0]).toEqual(['99:99'])
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['99:99'])
// Trigger blur event
await input.trigger('blur')
@ -40,8 +40,8 @@ describe('TimePicker', () => {
expect(wrapper.vm.timeValue).toBe('23:59') // Maximum allowed value for hours and minutes
// Check if update:modelValue event is emitted with corrected value
expect(wrapper.emitted('input')).toBeTruthy()
expect(wrapper.emitted('input')[1]).toEqual(['23:59'])
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')[1]).toEqual(['23:59'])
})
it('checks handling of empty input', async () => {
@ -58,7 +58,7 @@ describe('TimePicker', () => {
expect(wrapper.vm.timeValue).toBe('00:00')
// Check if update:modelValue event is emitted with default value
expect(wrapper.emitted('input')).toBeTruthy()
expect(wrapper.emitted('input')[1]).toEqual(['00:00'])
expect(wrapper.emitted('update:modelValue')).toBeTruthy()
expect(wrapper.emitted('update:modelValue')[1]).toEqual(['00:00'])
})
})

View File

@ -2,6 +2,7 @@
<div>
<input
v-model="timeValue"
class="timer-input"
type="text"
placeholder="hh:mm"
@input="updateValues"
@ -15,15 +16,15 @@ export default {
// Code written from chatGPT 3.5
name: 'TimePicker',
props: {
value: {
modelValue: {
type: String,
default: '00:00',
},
},
emits: ['input'],
emits: ['update:modelValue'],
data() {
return {
timeValue: this.value,
timeValue: this.modelValue,
}
},
methods: {
@ -31,7 +32,7 @@ export default {
// Allow only numbers and ":"
const inputValue = event.target.value.replace(/[^0-9:]/g, '')
this.timeValue = inputValue
this.$emit('input', inputValue)
this.$emit('update:modelValue', inputValue)
},
validateAndCorrect() {
let [hours, minutes] = this.timeValue.split(':')
@ -42,8 +43,16 @@ export default {
// Update the value with correct format
this.timeValue = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`
this.$emit('input', this.timeValue)
this.$emit('update:modelValue', this.timeValue)
},
},
}
</script>
<style scoped>
.timer-input {
border: 1px solid rgb(222 226 230);
border-radius: 6px;
padding: 6px 12px;
}
</style>

View File

@ -0,0 +1,10 @@
export const useDateFormatter = () => {
const formatDateFromDateTime = (datetimeString) => {
if (!datetimeString || !datetimeString?.includes('T')) return datetimeString
return datetimeString.split('T')[0]
}
return {
formatDateFromDateTime,
}
}

View File

@ -1,9 +1,9 @@
import { useI18n } from 'vue-i18n'
import { useToast } from 'bootstrap-vue-next'
import { useToastController } from 'bootstrap-vue-next'
export function useAppToast() {
const { t } = useI18n()
const { show } = useToast()
const { show } = useToastController()
const toastSuccess = (message) => {
toast(message, {
title: t('success'),

View File

@ -7,35 +7,50 @@ const pkg = require('../../package')
const constants = {
CONFIG_VERSION: {
DEFAULT: 'DEFAULT',
EXPECTED: 'v2.2024-01-04',
EXPECTED: 'v3.2024-08-06',
CURRENT: '',
},
}
const version = {
ADMIN_MODULE_PROTOCOL: process.env.ADMIN_MODULE_PROTOCOL ?? 'http',
ADMIN_MODULE_HOST: process.env.ADMIN_MODULE_HOST ?? '0.0.0.0',
ADMIN_MODULE_PORT: process.env.ADMIN_MODULE_PORT ?? '8080',
APP_VERSION: pkg.version,
BUILD_COMMIT: process.env.BUILD_COMMIT ?? null,
// self reference of `version.BUILD_COMMIT` is not possible at this point, hence the duplicate code
BUILD_COMMIT_SHORT: (process.env.BUILD_COMMIT ?? '0000000').slice(0, 7),
PORT: process.env.PORT ?? 8080,
}
let ADMIN_MODULE_URL
// in case of hosting the admin module with a nodejs-instance
if (process.env.ADMIN_HOSTING === 'nodejs') {
ADMIN_MODULE_URL =
version.ADMIN_MODULE_PROTOCOL +
'://' +
version.ADMIN_MODULE_HOST +
':' +
version.ADMIN_MODULE_PORT
} else {
// in case of hosting the admin module with a nginx
ADMIN_MODULE_URL = version.ADMIN_MODULE_PROTOCOL + '://' + version.ADMIN_MODULE_HOST
}
const environment = {
NODE_ENV: import.meta.env.NODE_ENV,
DEBUG: import.meta.env.NODE_ENV !== 'production' ?? false,
PRODUCTION: import.meta.env.NODE_ENV === 'production' ?? false,
NODE_ENV: process.env.NODE_ENV,
DEBUG: process.env.NODE_ENV !== 'production' ?? false,
PRODUCTION: process.env.NODE_ENV === 'production' ?? false,
}
const COMMUNITY_HOST = process.env.COMMUNITY_HOST ?? undefined
const URL_PROTOCOL = process.env.URL_PROTOCOL ?? 'http'
const COMMUNITY_URL =
COMMUNITY_HOST && URL_PROTOCOL ? URL_PROTOCOL + '://' + COMMUNITY_HOST : undefined
// const COMMUNITY_HOST = process.env.COMMUNITY_HOST ?? undefined
// const URL_PROTOCOL = process.env.URL_PROTOCOL ?? 'http'
// const COMMUNITY_URL =
// COMMUNITY_HOST && URL_PROTOCOL ? URL_PROTOCOL + '://' + COMMUNITY_HOST : undefined
const COMMUNITY_URL = process.env.COMMUNITY_URL ?? ADMIN_MODULE_URL
const WALLET_URL = process.env.WALLET_URL ?? COMMUNITY_URL ?? 'http://localhost'
const endpoints = {
GRAPHQL_URL:
(process.env.GRAPHQL_URL ?? COMMUNITY_URL ?? 'http://localhost:4000') +
process.env.GRAPHQL_PATH ?? '/graphql',
GRAPHQL_URI: process.env.GRAPHQL_URL ?? COMMUNITY_URL + (process.env.GRAPHQL_PATH ?? '/graphql'),
WALLET_AUTH_URL: WALLET_URL + (process.env.WALLET_AUTH_PATH ?? '/authenticate?token={token}'),
WALLET_LOGIN_URL: WALLET_URL + (process.env.WALLET_LOGIN_PATH ?? '/login'),
}
@ -64,4 +79,4 @@ const CONFIG = {
...debug,
}
export default CONFIG
module.exports = CONFIG

View File

@ -7,13 +7,19 @@ import IconsResolve from 'unplugin-icons/resolver'
import { BootstrapVueNextResolver } from 'bootstrap-vue-next'
import EnvironmentPlugin from 'vite-plugin-environment'
import dotenv from 'dotenv'
dotenv.config() // load env vars from .env
const CONFIG = require('./src/config')
const path = require('path')
export default defineConfig({
base: '/admin/',
server: {
host: '0.0.0.0',
port: 8080,
host: CONFIG.ADMIN_MODULE_HOST, // '0.0.0.0',
port: CONFIG.ADMIN_MODULE_PORT, // 8080,
},
resolve: {
alias: {
@ -42,21 +48,22 @@ export default defineConfig({
}),
EnvironmentPlugin({
BUILD_COMMIT: null,
PORT: null,
COMMUNITY_HOST: null,
URL_PROTOCOL: null,
WALLET_URL: null,
GRAPHQL_URL: null,
GRAPHQL_PATH: null,
WALLET_AUTH_PATH: null,
WALLET_LOGIN_PATH: null,
DEBUG_DISABLE_AUTH: null,
CONFIG_VERSION: null,
PORT: CONFIG.ADMIN_MODULE_PORT, // null,
COMMUNITY_HOST: CONFIG.ADMIN_MODULE_HOST, // null,
URL_PROTOCOL: CONFIG.ADMIN_MODULE_PROTOCOL, // null,
WALLET_URL: CONFIG.WALLET_AUTH_URL, // null,
GRAPHQL_URL: CONFIG.GRAPHQL_URI, // null,
GRAPHQL_PATH: process.env.GRAPHQL_PATH ?? '/graphql', // null,
WALLET_AUTH_PATH: CONFIG.WALLET_AUTH_URL, // null,
WALLET_LOGIN_PATH: CONFIG.WALLET_LOGIN_URL, // null,
DEBUG_DISABLE_AUTH: CONFIG.DEBUG_DISABLE_AUTH, // null,
CONFIG_VERSION: CONFIG.CONFIG_VERSION, // null,
}),
commonjs(),
],
build: {
outDir: path.resolve(__dirname, './build'),
chunkSizeWarningLimit: 1600,
},
publicDir: '/admin',
})

View File

@ -7,7 +7,7 @@ const CONFIG = require('./src/config')
// vue.config.js
module.exports = {
devServer: {
port: CONFIG.PORT,
port: CONFIG.ADMIN_MODULE_PORT,
},
pluginOptions: {
i18n: {

File diff suppressed because it is too large Load Diff

78
backend/.env.org Normal file
View File

@ -0,0 +1,78 @@
# Server
PORT=4000
JWT_SECRET=secret123
JWT_EXPIRES_IN=10m
GRAPHIQL=false
GDT_API_URL=https://gdt.gradido.net
# Database
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=
DB_DATABASE=gradido_community
TYPEORM_LOGGING_RELATIVE_PATH=typeorm.backend.log
# Klicktipp
KLICKTIPP=false
KLICKTTIPP_API_URL=https://api.klicktipp.com
KLICKTIPP_USER=gradido_test
KLICKTIPP_PASSWORD=secret321
KLICKTIPP_APIKEY_DE=SomeFakeKeyDE
KLICKTIPP_APIKEY_EN=SomeFakeKeyEN
# DltConnector
DLT_CONNECTOR=true
DLT_CONNECTOR_URL=http://localhost:6010
# Community
COMMUNITY_NAME=Gradido Entwicklung
COMMUNITY_URL=http://localhost
COMMUNITY_REGISTER_PATH=/register
COMMUNITY_REDEEM_PATH=/redeem/{code}
COMMUNITY_REDEEM_CONTRIBUTION_PATH=/redeem/CL-{code}
COMMUNITY_DESCRIPTION=Die lokale Entwicklungsumgebung von Gradido.
COMMUNITY_SUPPORT_MAIL=support@supportmail.com
# Login Server
LOGIN_APP_SECRET=21ffbbc616fe
LOGIN_SERVER_KEY=a51ef8ac7ef1abf162fb7a65261acd7a
# EMail
EMAIL=false
EMAIL_TEST_MODUS=false
EMAIL_TEST_RECEIVER=stage1@gradido.net
EMAIL_USERNAME=gradido_email
EMAIL_SENDER=info@gradido.net
EMAIL_PASSWORD=xxx
EMAIL_SMTP_URL=gmail.com
EMAIL_SMTP_PORT=587
EMAIL_LINK_VERIFICATION_PATH=/checkEmail/{optin}{code}
EMAIL_LINK_SETPASSWORD_PATH=/reset-password/{optin}
EMAIL_LINK_FORGOTPASSWORD_PATH=/forgot-password
EMAIL_LINK_OVERVIEW_PATH=/overview
EMAIL_CODE_VALID_TIME=1440
EMAIL_CODE_REQUEST_TIME=10
# Webhook
WEBHOOK_ELOPAGE_SECRET=secret
# SET LOG LEVEL AS NEEDED IN YOUR .ENV
# POSSIBLE VALUES: all | trace | debug | info | warn | error | fatal
LOG_LEVEL=INFO
# Federation
FEDERATION_VALIDATE_COMMUNITY_TIMER=60000
FEDERATION_XCOM_SENDCOINS_ENABLED=false
# GMS
# GMS_ACTIVE=true
# Coordinates of Illuminz test instance
#GMS_API_URL=http://54.176.169.179:3071
GMS_API_URL=http://localhost:4044
GMS_DASHBOARD_URL=http://localhost:8080
# HUMHUB
HUMHUB_ACTIVE=true
HUMHUB_API_URL=https://community-test.gradido.net
HUMHUB_JWT_KEY=GwdkIKi-rkRS0mXC4Cg3MYc3ktZh89VFmntDpNKET_dUfcIdjL_957F3nCv3brNtDfbbV81NViKaktUsfExrkH

View File

@ -40,6 +40,7 @@ COMMUNITY_SUPPORT_MAIL=$COMMUNITY_SUPPORT_MAIL
# Login Server
LOGIN_APP_SECRET=21ffbbc616fe
LOGIN_SERVER_KEY=a51ef8ac7ef1abf162fb7a65261acd7a
USE_CRYPTO_WORKER=$USE_CRYPTO_WORKER
# EMail
EMAIL=$EMAIL

View File

@ -51,7 +51,8 @@
"type-graphql": "^1.1.1",
"typed-rest-client": "^1.8.11",
"uuid": "^8.3.2",
"xregexp": "^5.1.1"
"xregexp": "^5.1.1",
"workerpool": "^9.2.0"
},
"devDependencies": {
"@eslint-community/eslint-plugin-eslint-comments": "^3.2.1",

View File

@ -32,6 +32,8 @@ import { raeuberHotzenplotz } from '@/seeds/users/raeuber-hotzenplotz'
import { sendTransactionsToDltConnector } from './sendTransactionsToDltConnector'
jest.mock('@/password/EncryptorUtils')
/*
// Mock the GraphQLClient
jest.mock('graphql-request', () => {

View File

@ -28,8 +28,8 @@ export class GmsUser {
status: number
createdAt: Date
updatedAt: Date
firstName: string | undefined
lastName: string | undefined
firstName: string | null | undefined
lastName: string | null | undefined
alias: string | undefined
type: number
address: string | undefined
@ -48,9 +48,19 @@ export class GmsUser {
) {
return user.alias
}
if (
user.gmsAllowed &&
((!user.alias && user.gmsPublishName === PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS) ||
user.gmsPublishName === PublishNameType.PUBLISH_NAME_INITIALS)
) {
return (
this.firstUpperCaseSecondLowerCase(user.firstName) +
this.firstUpperCaseSecondLowerCase(user.lastName)
)
}
}
private getGmsFirstName(user: dbUser): string | undefined {
private getGmsFirstName(user: dbUser): string | null | undefined {
if (
user.gmsAllowed &&
(user.gmsPublishName === PublishNameType.PUBLISH_NAME_FIRST ||
@ -64,22 +74,30 @@ export class GmsUser {
((!user.alias && user.gmsPublishName === PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS) ||
user.gmsPublishName === PublishNameType.PUBLISH_NAME_INITIALS)
) {
return user.firstName.substring(0, 1)
// return this.firstUpperCaseSecondLowerCase(user.firstName)
return null // cause to delete firstname in gms
}
}
private getGmsLastName(user: dbUser): string | undefined {
private getGmsLastName(user: dbUser): string | null | undefined {
if (user.gmsAllowed && user.gmsPublishName === PublishNameType.PUBLISH_NAME_FULL) {
return user.lastName
}
if (user.gmsAllowed && user.gmsPublishName === PublishNameType.PUBLISH_NAME_FIRST_INITIAL) {
return this.firstUpperCaseSecondLowerCase(user.lastName)
}
return null // cause to delete lastname in gms
/*
if (
user.gmsAllowed &&
((!user.alias && user.gmsPublishName === PublishNameType.PUBLISH_NAME_ALIAS_OR_INITALS) ||
user.gmsPublishName === PublishNameType.PUBLISH_NAME_FIRST_INITIAL ||
user.gmsPublishName === PublishNameType.PUBLISH_NAME_INITIALS)
) {
return user.lastName.substring(0, 1)
return this.firstUpperCaseSecondLowerCase(user.lastName)
}
*/
}
private getGmsEmail(user: dbUser): string | undefined {
@ -106,4 +124,11 @@ export class GmsUser {
return user.emailContact.phone
}
}
private firstUpperCaseSecondLowerCase(name: string) {
if (name && name.length >= 2) {
return name.charAt(0).toUpperCase() + name.charAt(1).toLocaleLowerCase()
}
return name
}
}

View File

@ -12,7 +12,7 @@ Decimal.set({
})
const constants = {
DB_VERSION: '0088-merge_dlt_tables',
DB_VERSION: '0089-merge_dlt_tables',
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info
@ -76,6 +76,7 @@ const community = {
const loginServer = {
LOGIN_APP_SECRET: process.env.LOGIN_APP_SECRET ?? '21ffbbc616fe',
LOGIN_SERVER_KEY: process.env.LOGIN_SERVER_KEY ?? 'a51ef8ac7ef1abf162fb7a65261acd7a',
USE_CRYPTO_WORKER: process.env.USE_CRYPTO_WORKER ?? false,
}
const email = {

View File

@ -0,0 +1,12 @@
import { Field, ObjectType } from 'type-graphql'
import { Location } from './Location'
@ObjectType()
export class UserLocationResult {
@Field(() => Location)
userLocation: Location
@Field(() => Location)
communityLocation: Location
}

View File

@ -28,6 +28,8 @@ import { peterLustig } from '@/seeds/users/peter-lustig'
import { getCommunityByUuid } from './util/communities'
jest.mock('@/password/EncryptorUtils')
// to do: We need a setup for the tests that closes the connection
let mutate: ApolloServerTestClient['mutate'],
query: ApolloServerTestClient['query'],

View File

@ -22,6 +22,8 @@ import { listContributionLinks } from '@/seeds/graphql/queries'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { peterLustig } from '@/seeds/users/peter-lustig'
jest.mock('@/password/EncryptorUtils')
let mutate: ApolloServerTestClient['mutate'],
query: ApolloServerTestClient['query'],
con: Connection

View File

@ -27,6 +27,7 @@ import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { bobBaumeister } from '@/seeds/users/bob-baumeister'
import { peterLustig } from '@/seeds/users/peter-lustig'
jest.mock('@/password/EncryptorUtils')
jest.mock('@/emails/sendEmailVariants', () => {
const originalModule = jest.requireActual('@/emails/sendEmailVariants')
return {

View File

@ -59,6 +59,7 @@ import { stephenHawking } from '@/seeds/users/stephen-hawking'
import { getFirstDayOfPreviousNMonth } from '@/util/utilities'
jest.mock('@/emails/sendEmailVariants')
jest.mock('@/password/EncryptorUtils')
let mutate: ApolloServerTestClient['mutate'],
query: ApolloServerTestClient['query'],

View File

@ -15,6 +15,8 @@ import { userFactory } from '@/seeds/factory/user'
import { login, subscribeNewsletter, unsubscribeNewsletter } from '@/seeds/graphql/mutations'
import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
jest.mock('@/password/EncryptorUtils')
let testEnv: any, mutate: any, con: any
beforeAll(async () => {

View File

@ -38,6 +38,8 @@ import { TRANSACTIONS_LOCK } from '@/util/TRANSACTIONS_LOCK'
import { transactionLinkCode } from './TransactionLinkResolver'
jest.mock('@/password/EncryptorUtils')
// mock semaphore to allow use fake timers
jest.mock('@/util/TRANSACTIONS_LOCK')
TRANSACTIONS_LOCK.acquire = jest.fn().mockResolvedValue(jest.fn())

View File

@ -37,6 +37,8 @@ import { garrickOllivander } from '@/seeds/users/garrick-ollivander'
import { peterLustig } from '@/seeds/users/peter-lustig'
import { stephenHawking } from '@/seeds/users/stephen-hawking'
jest.mock('@/password/EncryptorUtils')
let mutate: ApolloServerTestClient['mutate'], con: Connection
let query: ApolloServerTestClient['query']

View File

@ -74,6 +74,7 @@ import { objectValuesToArray } from '@/util/utilities'
import { Location2Point } from './util/Location2Point'
jest.mock('@/apis/humhub/HumHubClient')
jest.mock('@/password/EncryptorUtils')
jest.mock('@/emails/sendEmailVariants', () => {
const originalModule = jest.requireActual('@/emails/sendEmailVariants')
@ -96,6 +97,8 @@ jest.mock('@/apis/KlicktippController', () => {
}
})
CONFIG.EMAIL_CODE_REQUEST_TIME = 10
let admin: User
let user: User
let mutate: ApolloServerTestClient['mutate'],
@ -588,8 +591,8 @@ describe('UserResolver', () => {
expect(newUser.emailContact.emailChecked).toBeTruthy()
})
it('updates the password', () => {
const encryptedPass = encryptPassword(newUser, 'Aa12345_')
it('updates the password', async () => {
const encryptedPass = await encryptPassword(newUser, 'Aa12345_')
expect(newUser.password.toString()).toEqual(encryptedPass.toString())
})
@ -1547,9 +1550,9 @@ describe('UserResolver', () => {
expect(bibi).toEqual(
expect.objectContaining({
password: SecretKeyCryptographyCreateKey(bibi.gradidoID.toString(), 'Aa12345_')[0]
.readBigUInt64LE()
.toString(),
password: (
await SecretKeyCryptographyCreateKey(bibi.gradidoID.toString(), 'Aa12345_')
).toString(),
passwordEncryptionType: PasswordEncryptionType.GRADIDO_ID,
}),
)
@ -1571,10 +1574,7 @@ describe('UserResolver', () => {
})
bibi = usercontact.user
bibi.passwordEncryptionType = PasswordEncryptionType.EMAIL
bibi.password = SecretKeyCryptographyCreateKey(
'bibi@bloxberg.de',
'Aa12345_',
)[0].readBigUInt64LE()
bibi.password = await SecretKeyCryptographyCreateKey('bibi@bloxberg.de', 'Aa12345_')
await bibi.save()
})
@ -1591,9 +1591,9 @@ describe('UserResolver', () => {
expect(bibi).toEqual(
expect.objectContaining({
firstName: 'Bibi',
password: SecretKeyCryptographyCreateKey(bibi.gradidoID.toString(), 'Aa12345_')[0]
.readBigUInt64LE()
.toString(),
password: (
await SecretKeyCryptographyCreateKey(bibi.gradidoID.toString(), 'Aa12345_')
).toString(),
passwordEncryptionType: PasswordEncryptionType.GRADIDO_ID,
}),
)

View File

@ -2,12 +2,11 @@
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/restrict-template-expressions */
import { getConnection, In } from '@dbTools/typeorm'
import { getConnection, In, Point } from '@dbTools/typeorm'
import { ContributionLink as DbContributionLink } from '@entity/ContributionLink'
import { TransactionLink as DbTransactionLink } from '@entity/TransactionLink'
import { User as DbUser } from '@entity/User'
import { UserContact as DbUserContact } from '@entity/UserContact'
import { UserRole } from '@entity/UserRole'
import i18n from 'i18n'
import { Resolver, Query, Args, Arg, Authorized, Ctx, Mutation, Int } from 'type-graphql'
import { IRestResponse } from 'typed-rest-client'
@ -29,9 +28,8 @@ import { SearchAdminUsersResult } from '@model/AdminUser'
import { GmsUserAuthenticationResult } from '@model/GmsUserAuthenticationResult'
import { User } from '@model/User'
import { UserAdmin, SearchUsersResult } from '@model/UserAdmin'
import { UserLocationResult } from '@model/UserLocationResult'
import { updateGmsUser } from '@/apis/gms/GmsClient'
import { GmsUser } from '@/apis/gms/model/GmsUser'
import { HumHubClient } from '@/apis/humhub/HumHubClient'
import { GetUser } from '@/apis/humhub/model/GetUser'
import { PostUser } from '@/apis/humhub/model/PostUser'
@ -74,6 +72,7 @@ import {
TRANSMIT_TO_IOTA_INTERRUPTIVE_SLEEP_KEY,
} from '@/util/InterruptiveSleepManager'
import { getTimeDurationObject, printTimeDuration } from '@/util/time'
import { delay } from '@/util/utilities'
import random from 'random-bigint'
import { randombytes_random } from 'sodium-native'
@ -86,7 +85,7 @@ import { getUserCreations } from './util/creations'
import { findUserByIdentifier } from './util/findUserByIdentifier'
import { findUsers } from './util/findUsers'
import { getKlicktippState } from './util/getKlicktippState'
import { Location2Point } from './util/Location2Point'
import { Location2Point, Point2Location } from './util/Location2Point'
import { setUserRole, deleteUserRole } from './util/modifyUserRole'
import { sendUserToGms } from './util/sendUserToGms'
import { syncHumhub } from './util/syncHumhub'
@ -155,7 +154,16 @@ export class UserResolver {
): Promise<User> {
logger.info(`login with ${email}, ***, ${publisherId} ...`)
email = email.trim().toLowerCase()
const dbUser = await findUserByEmail(email)
let dbUser: DbUser
try {
dbUser = await findUserByEmail(email)
} catch (e) {
// simulate delay which occur on password encryption 650 ms +- 50 rnd
await delay(650 + Math.floor(Math.random() * 101) - 50)
throw e
}
if (dbUser.deletedAt) {
throw new LogError('This user was permanently deleted. Contact support for questions', dbUser)
}
@ -167,7 +175,7 @@ export class UserResolver {
// TODO we want to catch this on the frontend and ask the user to check his emails or resend code
throw new LogError('The User has not set a password yet', dbUser)
}
if (!verifyPassword(dbUser, password)) {
if (!(await verifyPassword(dbUser, password))) {
throw new LogError('No user with this credentials', dbUser)
}
@ -183,7 +191,7 @@ export class UserResolver {
if (dbUser.passwordEncryptionType !== PasswordEncryptionType.GRADIDO_ID) {
dbUser.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
dbUser.password = encryptPassword(dbUser, password)
dbUser.password = await encryptPassword(dbUser, password)
await dbUser.save()
}
// add pubKey in logger-context for layout-pattern X{user} to print it in each logging message
@ -510,7 +518,7 @@ export class UserResolver {
// Update Password
user.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
user.password = encryptPassword(user, password)
user.password = await encryptPassword(user, password)
logger.debug('User credentials updated ...')
const queryRunner = getConnection().createQueryRunner()
@ -640,13 +648,13 @@ export class UserResolver {
)
}
if (!verifyPassword(user, password)) {
if (!(await verifyPassword(user, password))) {
throw new LogError(`Old password is invalid`)
}
// Save new password hash and newly encrypted private key
user.passwordEncryptionType = PasswordEncryptionType.GRADIDO_ID
user.password = encryptPassword(user, passwordNew)
user.password = await encryptPassword(user, passwordNew)
}
// Save hideAmountGDD value
@ -704,9 +712,9 @@ export class UserResolver {
logger.debug(`changed user-settings relevant for gms-user update...`)
const homeCom = await getHomeCommunity()
if (homeCom.gmsApiKey !== null) {
logger.debug(`gms-user update...`, user)
await updateGmsUser(homeCom.gmsApiKey, new GmsUser(user))
logger.debug(`gms-user update successfully.`)
logger.debug(`send User to Gms...`, user)
await sendUserToGms(user, homeCom)
logger.debug(`sendUserToGms successfully.`)
}
}
} catch (e) {
@ -736,14 +744,35 @@ export class UserResolver {
@Authorized([RIGHTS.GMS_USER_PLAYGROUND])
@Query(() => GmsUserAuthenticationResult)
async authenticateGmsUserSearch(@Ctx() context: Context): Promise<GmsUserAuthenticationResult> {
logger.info(`authUserForGmsUserSearch()...`)
logger.info(`authenticateGmsUserSearch()...`)
const dbUser = getUser(context)
let result: GmsUserAuthenticationResult
let result = new GmsUserAuthenticationResult()
if (context.token) {
result = await authenticateGmsUserPlayground(context.token, dbUser)
logger.info('authUserForGmsUserSearch=', result)
const homeCom = await getHomeCommunity()
if (!homeCom.gmsApiKey) {
throw new LogError('authenticateGmsUserSearch missing HomeCommunity GmsApiKey')
}
result = await authenticateGmsUserPlayground(homeCom.gmsApiKey, context.token, dbUser)
logger.info('authenticateGmsUserSearch=', result)
} else {
throw new LogError('authUserForGmsUserSearch without token')
throw new LogError('authenticateGmsUserSearch missing valid user login-token')
}
return result
}
@Authorized([RIGHTS.GMS_USER_PLAYGROUND])
@Query(() => UserLocationResult)
async userLocation(@Ctx() context: Context): Promise<UserLocationResult> {
logger.info(`userLocation()...`)
const dbUser = getUser(context)
const result = new UserLocationResult()
if (context.token) {
const homeCom = await getHomeCommunity()
result.communityLocation = Point2Location(homeCom.location as Point)
result.userLocation = Point2Location(dbUser.location as Point)
logger.info('userLocation=', result)
} else {
throw new LogError('userLocation missing valid user login-token')
}
return result
}
@ -979,16 +1008,15 @@ export class UserResolver {
}
export async function findUserByEmail(email: string): Promise<DbUser> {
const dbUserContact = await DbUserContact.findOneOrFail({
where: { email },
const dbUser = await DbUser.findOneOrFail({
where: {
emailContact: { email },
},
withDeleted: true,
relations: ['user'],
relations: { userRoles: true, emailContact: true },
}).catch(() => {
throw new LogError('No user with this credentials', email)
})
const dbUser = dbUserContact.user
dbUser.emailContact = dbUserContact
dbUser.userRoles = await UserRole.find({ where: { userId: dbUser.id } })
return dbUser
}

View File

@ -25,6 +25,8 @@ import { bibiBloxberg } from '@/seeds/users/bibi-bloxberg'
import { bobBaumeister } from '@/seeds/users/bob-baumeister'
import { peterLustig } from '@/seeds/users/peter-lustig'
jest.mock('@/password/EncryptorUtils')
let mutate: ApolloServerTestClient['mutate'], con: Connection
let testEnv: {
mutate: ApolloServerTestClient['mutate']

View File

@ -7,13 +7,14 @@ import { backendLogger as logger } from '@/server/logger'
import { ensureUrlEndsWithSlash } from '@/util/utilities'
export async function authenticateGmsUserPlayground(
apiKey: string,
token: string,
dbUser: DbUser,
): Promise<GmsUserAuthenticationResult> {
const result = new GmsUserAuthenticationResult()
const dashboardUrl = ensureUrlEndsWithSlash(CONFIG.GMS_DASHBOARD_URL)
result.url = dashboardUrl.concat('playground')
result.url = dashboardUrl.concat('usersearch-playground')
result.token = await verifyAuthToken(dbUser.communityUuid, token)
logger.info('GmsUserAuthenticationResult:', result)
return result

View File

@ -12,6 +12,8 @@ import { peterLustig } from '@/seeds/users/peter-lustig'
import { getOpenCreations, getUserCreation } from './creations'
jest.mock('@/password/EncryptorUtils')
let mutate: ApolloServerTestClient['mutate'], con: Connection
let testEnv: {
mutate: ApolloServerTestClient['mutate']

View File

@ -16,6 +16,8 @@ import { peterLustig } from '@/seeds/users/peter-lustig'
import { findUserByIdentifier } from './findUserByIdentifier'
jest.mock('@/password/EncryptorUtils')
let con: Connection
let testEnv: {
mutate: ApolloServerTestClient['mutate']

View File

@ -1,7 +1,7 @@
import { Community as DbCommunity } from '@entity/Community'
import { User as DbUser } from '@entity/User'
import { createGmsUser } from '@/apis/gms/GmsClient'
import { createGmsUser, updateGmsUser } from '@/apis/gms/GmsClient'
import { GmsUser } from '@/apis/gms/model/GmsUser'
import { CONFIG } from '@/config'
import { LogError } from '@/server/LogError'
@ -14,13 +14,20 @@ export async function sendUserToGms(user: DbUser, homeCom: DbCommunity): Promise
logger.debug('User send to GMS:', user)
const gmsUser = new GmsUser(user)
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
if (await createGmsUser(homeCom.gmsApiKey, gmsUser)) {
logger.debug('GMS user published successfully:', gmsUser)
user.gmsRegistered = true
user.gmsRegisteredAt = new Date()
await DbUser.save(user)
logger.debug('mark user as gms published:', user)
if (!user.gmsRegistered && user.gmsRegisteredAt === null) {
logger.debug('create user in gms:', gmsUser)
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
if (await createGmsUser(homeCom.gmsApiKey, gmsUser)) {
logger.debug('GMS user published successfully:', gmsUser)
await updateUserGmsStatus(user)
}
} else {
logger.debug('update user in gms:', gmsUser)
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
if (await updateGmsUser(homeCom.gmsApiKey, gmsUser)) {
logger.debug('GMS user published successfully:', gmsUser)
await updateUserGmsStatus(user)
}
}
} catch (err) {
if (CONFIG.GMS_CREATE_USER_THROW_ERRORS) {
@ -30,3 +37,11 @@ export async function sendUserToGms(user: DbUser, homeCom: DbCommunity): Promise
}
}
}
async function updateUserGmsStatus(user: DbUser) {
logger.debug('updateUserGmsStatus:', user)
user.gmsRegistered = true
user.gmsRegisteredAt = new Date()
await DbUser.save(user)
logger.debug('mark user as gms published:', user)
}

View File

@ -0,0 +1,53 @@
import { worker } from 'workerpool'
import { CONFIG } from '@/config'
import {
crypto_box_SEEDBYTES,
crypto_hash_sha512_init,
crypto_hash_sha512_update,
crypto_hash_sha512_final,
crypto_hash_sha512_BYTES,
crypto_hash_sha512_STATEBYTES,
crypto_shorthash_BYTES,
crypto_pwhash_SALTBYTES,
crypto_pwhash,
crypto_shorthash,
} from 'sodium-native'
export const SecretKeyCryptographyCreateKey = (
salt: string,
password: string,
configLoginAppSecret: Buffer,
configLoginServerKey: Buffer,
): bigint => {
const state = Buffer.alloc(crypto_hash_sha512_STATEBYTES)
crypto_hash_sha512_init(state)
crypto_hash_sha512_update(state, Buffer.from(salt))
crypto_hash_sha512_update(state, configLoginAppSecret)
const hash = Buffer.alloc(crypto_hash_sha512_BYTES)
crypto_hash_sha512_final(state, hash)
const encryptionKey = Buffer.alloc(crypto_box_SEEDBYTES)
const opsLimit = 10
const memLimit = 33554432
const algo = 2
crypto_pwhash(
encryptionKey,
Buffer.from(password),
hash.slice(0, crypto_pwhash_SALTBYTES),
opsLimit,
memLimit,
algo,
)
const encryptionKeyHash = Buffer.alloc(crypto_shorthash_BYTES)
crypto_shorthash(encryptionKeyHash, encryptionKey, configLoginServerKey)
return encryptionKeyHash.readBigUInt64LE()
}
if (CONFIG.USE_CRYPTO_WORKER) {
worker({
SecretKeyCryptographyCreateKey,
})
}

View File

@ -2,7 +2,11 @@
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { cpus } from 'os'
import path from 'path'
import { User } from '@entity/User'
import { Pool, pool } from 'workerpool'
import { PasswordEncryptionType } from '@enum/PasswordEncryptionType'
@ -10,61 +14,73 @@ import { CONFIG } from '@/config'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
import {
crypto_shorthash_KEYBYTES,
crypto_box_SEEDBYTES,
crypto_hash_sha512_init,
crypto_hash_sha512_update,
crypto_hash_sha512_final,
crypto_hash_sha512_BYTES,
crypto_hash_sha512_STATEBYTES,
crypto_shorthash_BYTES,
crypto_pwhash_SALTBYTES,
crypto_pwhash,
crypto_shorthash,
} from 'sodium-native'
import { crypto_shorthash_KEYBYTES } from 'sodium-native'
import { SecretKeyCryptographyCreateKey as SecretKeyCryptographyCreateKeySync } from './EncryptionWorker'
const configLoginAppSecret = Buffer.from(CONFIG.LOGIN_APP_SECRET, 'hex')
const configLoginServerKey = Buffer.from(CONFIG.LOGIN_SERVER_KEY, 'hex')
let encryptionWorkerPool: Pool | undefined
if (CONFIG.USE_CRYPTO_WORKER) {
encryptionWorkerPool = pool(
path.join(__dirname, '..', '..', 'build', 'src', 'password', '/EncryptionWorker.js'),
{
// TODO: put maxQueueSize into config
maxQueueSize: 30 * cpus().length,
},
)
}
// We will reuse this for changePassword
export const isValidPassword = (password: string): boolean => {
return !!password.match(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^a-zA-Z0-9 \\t\\n\\r]).{8,}$/)
}
export const SecretKeyCryptographyCreateKey = (salt: string, password: string): Buffer[] => {
logger.trace('SecretKeyCryptographyCreateKey...')
const configLoginAppSecret = Buffer.from(CONFIG.LOGIN_APP_SECRET, 'hex')
const configLoginServerKey = Buffer.from(CONFIG.LOGIN_SERVER_KEY, 'hex')
if (configLoginServerKey.length !== crypto_shorthash_KEYBYTES) {
throw new LogError(
'ServerKey has an invalid size',
configLoginServerKey.length,
crypto_shorthash_KEYBYTES,
)
/**
* @param salt
* @param password
* @returns can throw an exception if worker pool is full, if more than 30 * cpu core count logins happen in a time range of 30 seconds
*/
export const SecretKeyCryptographyCreateKey = async (
salt: string,
password: string,
): Promise<bigint> => {
try {
logger.trace('call worker for: SecretKeyCryptographyCreateKey')
if (configLoginServerKey.length !== crypto_shorthash_KEYBYTES) {
throw new LogError(
'ServerKey has an invalid size',
configLoginServerKey.length,
crypto_shorthash_KEYBYTES,
)
}
let result: Promise<bigint>
if (encryptionWorkerPool) {
result = (await encryptionWorkerPool.exec('SecretKeyCryptographyCreateKey', [
salt,
password,
configLoginAppSecret,
configLoginServerKey,
])) as Promise<bigint>
} else {
result = Promise.resolve(
SecretKeyCryptographyCreateKeySync(
salt,
password,
configLoginAppSecret,
configLoginServerKey,
),
)
}
return result
} catch (e) {
// pool is throwing this error
// throw new Error('Max queue size of ' + this.maxQueueSize + ' reached');
// will be shown in frontend to user
throw new LogError('Server is full, please try again in 10 minutes.', e)
}
const state = Buffer.alloc(crypto_hash_sha512_STATEBYTES)
crypto_hash_sha512_init(state)
crypto_hash_sha512_update(state, Buffer.from(salt))
crypto_hash_sha512_update(state, configLoginAppSecret)
const hash = Buffer.alloc(crypto_hash_sha512_BYTES)
crypto_hash_sha512_final(state, hash)
const encryptionKey = Buffer.alloc(crypto_box_SEEDBYTES)
const opsLimit = 10
const memLimit = 33554432
const algo = 2
crypto_pwhash(
encryptionKey,
Buffer.from(password),
hash.slice(0, crypto_pwhash_SALTBYTES),
opsLimit,
memLimit,
algo,
)
const encryptionKeyHash = Buffer.alloc(crypto_shorthash_BYTES)
crypto_shorthash(encryptionKeyHash, encryptionKey, configLoginServerKey)
return [encryptionKeyHash, encryptionKey]
}
export const getUserCryptographicSalt = (dbUser: User): string => {

View File

@ -3,13 +3,12 @@ import { User } from '@entity/User'
// import { logger } from '@test/testSetup' getting error "jest is not defined"
import { getUserCryptographicSalt, SecretKeyCryptographyCreateKey } from './EncryptorUtils'
export const encryptPassword = (dbUser: User, password: string): bigint => {
export const encryptPassword = async (dbUser: User, password: string): Promise<bigint> => {
const salt = getUserCryptographicSalt(dbUser)
const keyBuffer = SecretKeyCryptographyCreateKey(salt, password) // return short and long hash
const passwordHash = keyBuffer[0].readBigUInt64LE()
return passwordHash
return SecretKeyCryptographyCreateKey(salt, password)
}
export const verifyPassword = (dbUser: User, password: string): boolean => {
return dbUser.password.toString() === encryptPassword(dbUser, password).toString()
export const verifyPassword = async (dbUser: User, password: string): Promise<boolean> => {
const encryptedPassword = await encryptPassword(dbUser, password)
return dbUser.password.toString() === encryptedPassword.toString()
}

View File

@ -0,0 +1,114 @@
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-argument */
import { User } from '@entity/User'
import { PasswordEncryptionType } from '@enum/PasswordEncryptionType'
import { CONFIG } from '@/config'
import { LogError } from '@/server/LogError'
import { backendLogger as logger } from '@/server/logger'
import {
crypto_shorthash_KEYBYTES,
crypto_box_SEEDBYTES,
crypto_hash_sha512_init,
crypto_hash_sha512_update,
crypto_hash_sha512_final,
crypto_hash_sha512_BYTES,
crypto_hash_sha512_STATEBYTES,
crypto_shorthash_BYTES,
crypto_pwhash_SALTBYTES,
crypto_pwhash,
crypto_shorthash,
crypto_pwhash_OPSLIMIT_MIN,
crypto_pwhash_MEMLIMIT_MIN,
} from 'sodium-native'
const SecretKeyCryptographyCreateKeyMock = (
salt: string,
password: string,
configLoginAppSecret: Buffer,
configLoginServerKey: Buffer,
): bigint => {
const state = Buffer.alloc(crypto_hash_sha512_STATEBYTES)
crypto_hash_sha512_init(state)
crypto_hash_sha512_update(state, Buffer.from(salt))
crypto_hash_sha512_update(state, configLoginAppSecret)
const hash = Buffer.alloc(crypto_hash_sha512_BYTES)
crypto_hash_sha512_final(state, hash)
const encryptionKey = Buffer.alloc(crypto_box_SEEDBYTES)
const opsLimit = crypto_pwhash_OPSLIMIT_MIN
const memLimit = crypto_pwhash_MEMLIMIT_MIN
const algo = 2
crypto_pwhash(
encryptionKey,
Buffer.from(password),
hash.slice(0, crypto_pwhash_SALTBYTES),
opsLimit,
memLimit,
algo,
)
const encryptionKeyHash = Buffer.alloc(crypto_shorthash_BYTES)
crypto_shorthash(encryptionKeyHash, encryptionKey, configLoginServerKey)
return encryptionKeyHash.readBigUInt64LE()
}
const configLoginAppSecret = Buffer.from(CONFIG.LOGIN_APP_SECRET, 'hex')
const configLoginServerKey = Buffer.from(CONFIG.LOGIN_SERVER_KEY, 'hex')
// We will reuse this for changePassword
export const isValidPassword = (password: string): boolean => {
return !!password.match(/^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[^a-zA-Z0-9 \\t\\n\\r]).{8,}$/)
}
/**
* @param salt
* @param password
* @returns can throw an exception if worker pool is full, if more than 30 * cpu core count logins happen in a time range of 30 seconds
*/
export const SecretKeyCryptographyCreateKey = async (
salt: string,
password: string,
): Promise<bigint> => {
try {
logger.trace('call worker for: SecretKeyCryptographyCreateKey')
if (configLoginServerKey.length !== crypto_shorthash_KEYBYTES) {
throw new LogError(
'ServerKey has an invalid size',
configLoginServerKey.length,
crypto_shorthash_KEYBYTES,
)
}
return Promise.resolve(
SecretKeyCryptographyCreateKeyMock(
salt,
password,
configLoginAppSecret,
configLoginServerKey,
),
)
} catch (e) {
// pool is throwing this error
// throw new Error('Max queue size of ' + this.maxQueueSize + ' reached');
// will be shown in frontend to user
throw new LogError('Server is full, please try again in 10 minutes.', e)
}
}
export const getUserCryptographicSalt = (dbUser: User): string => {
switch (dbUser.passwordEncryptionType) {
case PasswordEncryptionType.NO_PASSWORD:
throw new LogError('User has no password set', dbUser.id)
case PasswordEncryptionType.EMAIL:
return dbUser.emailContact.email
case PasswordEncryptionType.GRADIDO_ID:
return dbUser.gradidoID
default:
throw new LogError('Unknown password encryption type', dbUser.passwordEncryptionType)
}
}

View File

@ -23,7 +23,6 @@ export const creationFactory = async (
mutation: login,
variables: { email: creation.email, password: 'Aa12345_' },
})
const {
data: { createContribution: contribution },
} = await mutate({ mutation: createContribution, variables: { ...creation } })

View File

@ -59,8 +59,8 @@ const run = async () => {
const userIds = await DbUser.createQueryBuilder()
.select('id')
.where({ foreign: false })
.andWhere('deleted_at is null')
.andWhere({ gmsRegistered: false })
// .andWhere('deleted_at is null')
// .andWhere({ gmsRegistered: false })
.getRawMany()
logger.debug('userIds:', userIds)

View File

@ -24,6 +24,15 @@ export const authenticateGmsUserSearch = gql`
}
`
export const userLocationQuery = gql`
query {
userLocation {
userLocation
communityLocation
}
}
`
export const queryOptIn = gql`
query ($optIn: String!) {
queryOptIn(optIn: $optIn)

View File

@ -19,6 +19,7 @@ import { peterLustig } from '@/seeds/users/peter-lustig'
import { exportEventDataToKlickTipp } from './klicktipp'
jest.mock('@/apis/KlicktippController')
jest.mock('@/password/EncryptorUtils')
let mutate: ApolloServerTestClient['mutate'], con: Connection
let testEnv: {

View File

@ -3726,7 +3726,7 @@ graceful-fs@^4.1.6, graceful-fs@^4.2.0:
integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==
"gradido-database@file:../database":
version "2.3.1"
version "2.4.1"
dependencies:
"@types/uuid" "^8.3.4"
cross-env "^7.0.3"
@ -7386,6 +7386,11 @@ word-wrap@^1.2.3, word-wrap@~1.2.3:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
workerpool@^9.2.0:
version "9.2.0"
resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-9.2.0.tgz#f74427cbb61234708332ed8ab9cbf56dcb1c4371"
integrity sha512-PKZqBOCo6CYkVOwAxWxQaSF2Fvb5Iv2fCeTP7buyWI2GiynWr46NcXSgK/idoV6e60dgCBfgYc+Un3HMvmqP8w==
wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"

View File

@ -0,0 +1,10 @@
/* eslint-disable @typescript-eslint/explicit-module-boundary-types */
/* eslint-disable @typescript-eslint/no-explicit-any */
export async function upgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE `user_roles` ADD INDEX user_id (user_id);')
}
export async function downgrade(queryFn: (query: string, values?: any[]) => Promise<Array<any>>) {
await queryFn('ALTER TABLE `user_roles` DROP INDEX `user_id`;')
}

View File

@ -41,6 +41,7 @@ DEPLOY_SEED_DATA=false
# if true all email will be send to EMAIL_TEST_RECEIVER instead of email address of user
EMAIL_TEST_MODUS=false
EMAIL_TEST_RECEIVER=test_team@gradido.net
USE_CRYPTO_WORKER=true
# Logging
LOG_LEVEL=INFO

View File

@ -4,7 +4,7 @@ import dotenv from 'dotenv'
dotenv.config()
const constants = {
DB_VERSION: '0088-merge_dlt_tables',
DB_VERSION: '0089-merge_dlt_tables',
LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info
LOG_LEVEL: process.env.LOG_LEVEL ?? 'info',

View File

@ -35,8 +35,8 @@ export default defineConfig({
excludeSpecPattern: '*.js',
baseUrl: 'http://localhost:3000',
chromeWebSecurity: false,
defaultCommandTimeout: 100000,
pageLoadTimeout: 120000,
defaultCommandTimeout: 200000,
pageLoadTimeout: 240000,
supportFile: 'cypress/support/index.ts',
viewportHeight: 720,
viewportWidth: 1280,

View File

@ -10,7 +10,7 @@ Decimal.set({
})
const constants = {
DB_VERSION: '0088-merge_dlt_tables',
DB_VERSION: '0089-merge_dlt_tables',
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
LOG4JS_CONFIG: 'log4js-config.json',
// default log level on production should be info

2
frontend/.gitignore vendored
View File

@ -2,6 +2,8 @@
node_modules/
build/
.cache/
.yarn/install-state.gz
npm-debug.log*
yarn-debug.log*
yarn-error.log*

View File

@ -76,7 +76,7 @@ FROM build as test
RUN apk add --no-cache bash jq
# Run command
CMD /bin/sh -c "yarn run dev"
CMD /bin/sh -c "yarn run start"
##################################################################################
# PRODUCTION (Does contain only "binary"- and static-files to reduce image size) #

View File

@ -23,9 +23,9 @@
"@babel/preset-env": "^7.13.12",
"@morev/vue-transitions": "^3.0.2",
"@types/leaflet": "^1.9.12",
"@vee-validate/i18n": "^4.13.2",
"@vee-validate/rules": "^4.13.2",
"@vee-validate/yup": "^4.13.2",
"@vee-validate/i18n": "^4.14.7",
"@vee-validate/rules": "^4.14.1",
"@vee-validate/yup": "^4.14.1",
"@vitejs/plugin-vue": "5.1.4",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"@vue/apollo-composable": "^4.0.2",
@ -35,7 +35,7 @@
"babel-core": "^7.0.0-bridge.0",
"babel-preset-vue": "^2.0.2",
"bootstrap": "^5.3.3",
"bootstrap-vue-next": "^0.23.3",
"bootstrap-vue-next": "0.26.8",
"clipboard-polyfill": "^4.0.0-rc1",
"date-fns": "^2.29.3",
"es6-promise": "^4.1.1",
@ -56,7 +56,7 @@
"vee-validate": "^4.13.2",
"vite": "3.2.10",
"vite-plugin-commonjs": "^0.10.1",
"vue": "3.4.31",
"vue": "3.5.13",
"vue-apollo": "^3.1.2",
"vue-flatpickr-component": "^8.1.2",
"vue-i18n": "^9.13.1",
@ -93,7 +93,7 @@
"postcss-html": "^1.3.0",
"postcss-scss": "^4.0.3",
"prettier": "^3.3.3",
"sass": "1.32.13",
"sass": "1.77.6",
"stylelint": "16.7.0",
"stylelint-config-recommended-vue": "1.5.0",
"stylelint-config-standard-scss": "13.1.0",

View File

@ -1,10 +1,15 @@
// Imports
const dotenv = require('dotenv')
const express = require('express')
const path = require('path')
dotenv.config() // load env vars from .env
const CONFIG = require('../src/config')
// Host & Port
const hostname = '127.0.0.1'
const port = import.meta.env.PORT || 3000
const hostname = CONFIG.FRONTEND_MODULE_HOST
const port = CONFIG.FRONTEND_MODULE_PORT
// Express Server
const app = express()

View File

@ -19,7 +19,7 @@ $h3-font-size: $font-size-base * 1.0625 ;
$h4-font-size: $font-size-base * 0.9375 ;
$h5-font-size: $font-size-base * 0.8125 ;
$h6-font-size: $font-size-base * 0.625 ;
$headings-margin-bottom: ($spacer / 2);
$headings-margin-bottom: calc($spacer / 2);
$headings-font-family: inherit ;
$headings-font-weight: $font-weight-bold ;
$headings-line-height: 1.5 ;

View File

@ -1,13 +1,24 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import CollapseLinksList from './CollapseLinksList'
import CollapseLinksList from './CollapseLinksList.vue'
import { createStore } from 'vuex'
import { createI18n } from 'vue-i18n'
import { BButton } from 'bootstrap-vue-next'
// Mock vue-i18n
const mockT = vi.fn((key, value) => `${key} ${value}`)
// Mock translations
const mockT = vi.fn((key, params) => {
switch (key) {
case 'link-load':
if (params === 0) return '1 more link'
return `${params.n} more links`
case 'link-load-more':
return `Load ${params.n} more links`
default:
return key
}
})
// Mock vue-i18n
vi.mock('vue-i18n', () => ({
createI18n: vi.fn(() => ({
global: {
@ -64,6 +75,7 @@ describe('CollapseLinksList', () => {
},
},
props: {
modelValue: 1,
transactionLinks: [
{
amount: '5',
@ -89,7 +101,6 @@ describe('CollapseLinksList', () => {
},
],
transactionLinkCount: 3,
value: 1,
pending: false,
pageSize: 5,
...props,
@ -111,8 +122,8 @@ describe('CollapseLinksList', () => {
await wrapper.find('.test-button-load-more').trigger('click')
})
it('emits input', () => {
expect(wrapper.emitted('input')).toEqual([[2]])
it('emits update:modelValue', () => {
expect(wrapper.emitted('update:modelValue')).toEqual([[2]])
})
})
@ -123,8 +134,8 @@ describe('CollapseLinksList', () => {
.vm.$emit('reset-transaction-link-list')
})
it('emits input ', () => {
expect(wrapper.emitted('input')).toEqual([[0]])
it('emits update:modelValue', () => {
expect(wrapper.emitted('update:modelValue')).toEqual([[0]])
})
})
@ -138,7 +149,7 @@ describe('CollapseLinksList', () => {
})
it('renders text in singular', () => {
expect(mockT).toHaveBeenCalledWith('link-load', 0)
expect(wrapper.find('.test-button-load-more').text()).toBe('1 more link')
})
})
@ -151,7 +162,7 @@ describe('CollapseLinksList', () => {
})
it('renders text in plural and shows the correct count of links', () => {
expect(mockT).toHaveBeenCalledWith('link-load', 0)
expect(wrapper.find('.test-button-load-more').text()).toBe('4 more links')
})
})
@ -162,11 +173,10 @@ describe('CollapseLinksList', () => {
transactionLinks: [{ id: 1 }, { id: 2 }],
pageSize: 5,
})
await wrapper.vm.$nextTick()
})
it('renders text with pageSize as number of links to load', () => {
expect(mockT).toHaveBeenCalledWith('link-load-more', { n: 5 })
expect(wrapper.find('.test-button-load-more').text()).toBe('Load 5 more links')
})
})
})

View File

@ -42,7 +42,7 @@ export default {
type: Number,
required: true,
},
value: { type: Number, required: true },
modelValue: { type: Number, required: true },
pageSize: { type: Number, default: 5 },
pending: { type: Boolean, default: false },
},
@ -56,10 +56,10 @@ export default {
},
methods: {
resetTransactionLinkList() {
this.$emit('input', 0)
this.$emit('update:modelValue', 0)
},
loadMoreLinks() {
this.$emit('input', this.value + 1)
this.$emit('update:modelValue', this.modelValue + 1)
},
},
}

View File

@ -56,7 +56,7 @@
block
variant="gradido"
:disabled="disabled"
@click="$emit('send-transaction'), (disabled = true)"
@click="($emit('send-transaction'), (disabled = true))"
>
{{ $t('form.send_now') }}
</BButton>

View File

@ -69,12 +69,6 @@ describe('InputUsername', () => {
expect(input.props('placeholder')).toBe('Username')
})
it('emits set-is-edit event when button is clicked', async () => {
const button = wrapper.findComponent({ name: 'BButton' })
await button.trigger('click')
expect(wrapper.emitted('set-is-edit')).toBeTruthy()
})
it('shows all errors when showAllErrors prop is true', async () => {
const errors = ['Error 1', 'Error 2']
vi.mocked(useField).mockReturnValue({

View File

@ -14,7 +14,7 @@
data-test="username"
@update:modelValue="usernameValue = $event"
/>
<BButton size="md" text="Button" variant="secondary" append @click="emitSetIsEdit">
<BButton size="md" text="Button" variant="secondary" append @click="clearInput">
<IBiXCircle style="height: 17px; width: 17px" />
</BButton>
</BInputGroup>
@ -47,18 +47,17 @@ import { ref, computed, watch, defineProps, defineEmits } from 'vue'
import { useField, useForm } from 'vee-validate'
const props = defineProps({
isEdit: { type: Boolean, default: false },
rules: { type: Object, default: () => ({ required: true }) },
name: { type: String, default: 'username' },
label: { type: String, default: 'Username' },
placeholder: { type: String, default: 'Username' },
modelValue: { type: String, required: true },
showAllErrors: { type: Boolean, default: false },
immediate: { type: Boolean, default: false },
unique: { type: Boolean, required: true },
initialUsernameValue: { type: String, default: '' },
})
const currentValue = ref(props?.modelValue)
const currentValue = ref(props.initialUsernameValue)
const {
meta: usernameMeta,
@ -69,11 +68,13 @@ const {
initialValue: currentValue,
})
const emit = defineEmits(['update:modelValue', 'set-is-edit'])
const clearInput = () => (usernameValue.value = '')
const labelFor = computed(() => `${props.name}-input-field`)
const emitSetIsEdit = (bool) => {
emit('set-is-edit', bool)
}
</script>
<style>
div#username-form > div > label {
margin-bottom: 8px;
}
</style>

View File

@ -1,242 +1,3 @@
// import { mount } from '@vue/test-utils'
// import Transaction from './Transaction'
// import Vue from 'vue'
// import flushPromises from 'flush-promises'
//
// const localVue = global.localVue
//
// const consoleErrorMock = jest.fn()
//
// describe('Transaction', () => {
// let wrapper
//
// const mocks = {
// $i18n: {
// locale: 'en',
// },
// $t: jest.fn((t) => t),
// $n: jest.fn((n) => n),
// $d: jest.fn((d) => d),
// }
//
// const Wrapper = () => {
// return mount(Transaction, { localVue, mocks })
// }
//
// describe('mount', () => {
// beforeEach(() => {
// wrapper = Wrapper()
// })
//
// it('renders the component', () => {
// expect(wrapper.find('div.gdt-transaction-list').exists()).toBeTruthy()
// })
//
// it('has a collapse icon bi-arrow-down-circle', () => {
// expect(wrapper.find('div.gdt-transaction-list').findAll('svg').at(1).classes()).toEqual([
// 'bi-arrow-down-circle',
// 'h1',
// 'b-icon',
// 'bi',
// 'text-muted',
// ])
// })
//
// describe('no valid GDT entry type', () => {
// beforeEach(async () => {
// // disable throwing Errors on warnings to catch the warning
// Vue.config.warnHandler = (w) => {}
// // eslint-disable-next-line no-console
// console.error = consoleErrorMock
// await wrapper.setProps({ gdtEntryType: 'NOT_VALID' })
// })
//
// it('throws an error', () => {
// expect(consoleErrorMock).toBeCalledWith(
// expect.objectContaining({ message: 'no lines for this type: NOT_VALID' }),
// )
// })
// })
//
// describe('default entry type FORM', () => {
// beforeEach(async () => {
// await wrapper.setProps({
// amount: 100,
// date: '2021-05-02T17:20:11+00:00',
// comment: 'This is a comment',
// factor: 17,
// gdt: 1700,
// id: 42,
// })
// })
//
// it('has the heart icon', () => {
// expect(wrapper.find('svg.bi-heart').exists()).toBeTruthy()
// })
//
// it('has the description gdt.contribution', () => {
// expect(wrapper.findAll('div.row').at(0).text()).toContain('gdt.contribution')
// })
//
// it('renders the amount of euros', () => {
// expect(wrapper.findAll('div.row').at(0).text()).toContain('100 €')
// })
//
// it('renders the amount of GDT', () => {
// expect(wrapper.findAll('div.row').at(0).text()).toContain('1700 GDT')
// })
//
// it.skip('renders the comment message', () => {
// expect(wrapper.findAll('div.row').at(0).text()).toContain('This is a comment')
// })
//
// it.skip('renders the date', () => {
// expect(wrapper.findAll('div.row').at(0).text()).toContain('Sun May 02 2021')
// })
//
// it('does not show the collapse by default', () => {
// expect(wrapper.find('div#gdt-collapse-42').isVisible()).toBeFalsy()
// })
//
// describe('without comment', () => {
// it('does not render the message row', async () => {
// await wrapper.setProps({ comment: undefined })
// expect(wrapper.findAll('div.row').at(1).text()).toContain('gdt.calculation')
// })
// })
// // how to open the collapse ?????
// describe.skip('collapse is open', () => {
// beforeEach(async () => {
// await wrapper.find('div#gdt-collapse-42').trigger('click')
// await wrapper.vm.$nextTick()
// await flushPromises()
// await wrapper.vm.$nextTick()
// await flushPromises()
// })
//
// it('shows the collapse', () => {
// expect(wrapper.find('div#gdt-collapse-42').isVisible()).toBeTruthy()
// })
// })
// })
//
// describe('GdtEntryType.CVS', () => {
// it('behaves as default FORM', async () => {
// await wrapper.setProps({ gdtEntryType: 'CVS' })
// expect(wrapper.find('svg.bi-heart').exists()).toBeTruthy()
// })
// })
//
// describe('GdtEntryType.ELOPAGE', () => {
// it('behaves as default FORM', async () => {
// await wrapper.setProps({ gdtEntryType: 'ELOPAGE' })
// expect(wrapper.find('svg.bi-heart').exists()).toBeTruthy()
// })
// })
//
// describe('GdtEntryType.DIGISTORE', () => {
// it('behaves as default FORM', async () => {
// await wrapper.setProps({ gdtEntryType: 'DIGISTORE' })
// expect(wrapper.find('svg.bi-heart').exists()).toBeTruthy()
// })
// })
//
// describe('GdtEntryType.CVS2', () => {
// it('behaves as default FORM', async () => {
// await wrapper.setProps({ gdtEntryType: 'CVS2' })
// expect(wrapper.find('svg.bi-heart').exists()).toBeTruthy()
// })
// })
//
// describe('GdtEntryType.ELOPAGE_PUBLISHER', () => {
// beforeEach(async () => {
// await wrapper.setProps({
// amount: 365.67,
// date: '2020-04-10T13:28:00+00:00',
// comment: 'This is a comment',
// gdtEntryType: 'ELOPAGE_PUBLISHER',
// factor: 22,
// gdt: 967.65,
// id: 42,
// })
// })
//
// it('has the person-check icon', () => {
// expect(wrapper.find('svg.bi-person-check').exists()).toBeTruthy()
// })
//
// it('has the description gdt.recruited-member', () => {
// expect(wrapper.findAll('div.row').at(0).text()).toContain('gdt.recruited-member')
// })
//
// it('renders the percentage', () => {
// expect(wrapper.findAll('div.row').at(0).text()).toContain('5%')
// })
//
// it('renders the amount of GDT', () => {
// expect(wrapper.findAll('div.row').at(0).text()).toContain('365.67 GDT')
// })
//
// it('renders the gdt.publisher', () => {
// expect(wrapper.findAll('div.row').at(1).text()).toContain('gdt.publisher')
// })
//
// it.skip('renders the date', () => {
// expect(wrapper.findAll('div.row').at(2).text()).toContain('Fri Apr 10 2020')
// })
//
// it('does not show the collapse by default', () => {
// expect(wrapper.find('div#gdt-collapse-42').isVisible()).toBeFalsy()
// })
//
// describe.skip('without comment', () => {
// it('does not render the message row', async () => {
// await wrapper.setProps({ comment: undefined })
// expect(wrapper.findAll('div.row').at(0).text()).toContain('form.date')
// })
// })
// })
//
// describe('GdtEntryType.GLOBAL_MODIFICATOR', () => {
// beforeEach(async () => {
// await wrapper.setProps({
// amount: 123.45,
// date: '2020-03-12T13:28:00+00:00',
// comment: 'This is a comment',
// gdtEntryType: 'GLOBAL_MODIFICATOR',
// factor: 19,
// gdt: 61.23,
// id: 42,
// })
// })
//
// it('has the gift icon', () => {
// expect(wrapper.find('svg.bi-gift').exists()).toBeTruthy()
// })
//
// it('has the description gdt.gdt-received', () => {
// expect(wrapper.findAll('div.row').at(0).text()).toContain('gdt.gdt-received')
// })
//
// it('renders the comment', () => {
// expect(wrapper.findAll('div.row').at(0).text()).toContain('This is a comment')
// })
//
// it('renders the amount of GDT', () => {
// expect(wrapper.findAll('div.row').at(0).text()).toContain('61.23 GDT')
// })
//
// it('renders the gdt.conversion-gdt-euro', () => {
// expect(wrapper.findAll('div.row').at(1).text()).toContain('gdt.conversion-gdt-euro')
// })
//
// it('does not show the collapse by default', () => {
// expect(wrapper.find('div#gdt-collapse-42').isVisible()).toBeFalsy()
// })
// })
// })
// })
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import Transaction from './Transaction'
@ -266,8 +27,11 @@ vi.mock('vue-i18n', () => ({
describe('Transaction', () => {
let wrapper
const Wrapper = () => {
const createWrapper = (props = {}, options = {}) => {
return mount(Transaction, {
props: {
...props,
},
global: {
plugins: [
createStore({
@ -285,8 +49,21 @@ describe('Transaction', () => {
BRow,
BCol,
BAvatar,
BCollapse,
BCollapse: {
template: `
<div
:id="id"
class="collapse"
:class="{ show: modelValue }"
data-test="collapse"
>
<slot/>
</div>
`,
props: ['id', 'modelValue'],
},
VariantIcon,
...options.stubs,
},
},
})
@ -294,7 +71,7 @@ describe('Transaction', () => {
describe('mount', () => {
beforeEach(() => {
wrapper = Wrapper()
wrapper = createWrapper()
})
it('renders the component', () => {
@ -330,7 +107,7 @@ describe('Transaction', () => {
describe('default entry type FORM', () => {
beforeEach(async () => {
await wrapper.setProps({
wrapper = createWrapper({
amount: 100,
date: '2021-05-02T17:20:11+00:00',
comment: 'This is a comment',
@ -356,10 +133,6 @@ describe('Transaction', () => {
expect(wrapper.findAll('div.row').at(0).text()).toContain('1700 GDT')
})
// it('does not show the collapse by default', () => {
// expect(wrapper.find('div#gdt-collapse-42').isVisible()).toBe(false)
// })
describe('without comment', () => {
it('does not render the message row', async () => {
await wrapper.setProps({ comment: undefined })
@ -374,7 +147,10 @@ describe('Transaction', () => {
})
it('shows the collapse', () => {
expect(wrapper.find('div#gdt-collapse-42').isVisible()).toBe(true)
const collapse = wrapper.find('div#gdt-collapse-42')
expect(collapse.exists()).toBe(true)
expect(collapse.attributes('data-test')).toBe('collapse')
expect(wrapper.find('[data-test="collapse"]').classes()).toContain('show')
})
})
})
@ -409,7 +185,7 @@ describe('Transaction', () => {
describe('GdtEntryType.ELOPAGE_PUBLISHER', () => {
beforeEach(async () => {
await wrapper.setProps({
wrapper = createWrapper({
amount: 365.67,
date: '2020-04-10T13:28:00+00:00',
comment: 'This is a comment',
@ -435,15 +211,11 @@ describe('Transaction', () => {
it('renders the amount of GDT', () => {
expect(wrapper.findAll('div.row').at(0).text()).toContain('365.67 GDT')
})
// it('does not show the collapse by default', () => {
// expect(wrapper.find('div#gdt-collapse-42').isVisible()).toBe(false)
// })
})
describe('GdtEntryType.GLOBAL_MODIFICATOR', () => {
beforeEach(async () => {
await wrapper.setProps({
wrapper = createWrapper({
amount: 123.45,
date: '2020-03-12T13:28:00+00:00',
comment: 'This is a comment',
@ -452,7 +224,6 @@ describe('Transaction', () => {
gdt: 61.23,
id: 42,
})
wrapper.attachTo = document.body
})
it('has the gift icon', () => {
@ -470,10 +241,6 @@ describe('Transaction', () => {
it('renders the amount of GDT', () => {
expect(wrapper.findAll('div.row').at(0).text()).toContain('61.23 GDT')
})
// it('does not show the collapse by default', () => {
// expect(wrapper.find('div#gdt-collapse-42').isVisible()).toBe(false)
// })
})
})
})

View File

@ -14,7 +14,10 @@
</BCol>
<BCol>
<div>
<component :is="nameComponent" v-bind="nameProps" />
<Name v-if="useNameComponent" v-bind="nameProps" />
<div v-else :class="nameProps.class">
{{ nameProps.creationLinkedUser }}
</div>
</div>
<span class="small">{{ $d(new Date(props.transaction.balanceDate), 'short') }}</span>
<span class="ms-4 small">{{ $d(new Date(props.transaction.balanceDate), 'time') }}</span>
@ -112,14 +115,15 @@ const avatarProps = computed(() => {
}
})
const nameComponent = computed(() => {
return isCreationType.value ? 'div' : Name
const useNameComponent = computed(() => {
return !isCreationType.value
})
const nameProps = computed(() => {
if (isCreationType.value) {
return {
class: 'fw-bold',
creationLinkedUser: `${props.transaction.linkedUser.firstName} ${props.transaction.linkedUser.lastName}`,
}
} else {
return {

View File

@ -1,7 +1,6 @@
<template>
<div class="user-gms-location-format">
<BDropdown v-model="selectedOption">
<template #button-content>{{ selectedOptionLabel }}</template>
<BDropdown :text="selectedOptionLabel">
<BDropdownItem
v-for="option in dropdownOptions"
:key="option.value"

View File

@ -47,11 +47,12 @@ const communityLocation = ref({ lat: 0, lng: 0 })
const emit = defineEmits(['close'])
onResult(({ data }) => {
communityLocation.value.lng = data.userLocation.longitude
communityLocation.value.lat = data.userLocation.latitude
const locationData = data.userLocation
communityLocation.value.lng = locationData.communityLocation.longitude
communityLocation.value.lat = locationData.communityLocation.latitude
userLocation.value.lng = data?.userLocation?.longitude ?? communityLocation.value.lng
userLocation.value.lat = data?.userLocation?.latitude ?? communityLocation.value.lat
userLocation.value.lng = locationData.userLocation?.longitude ?? communityLocation.value.lng
userLocation.value.lat = locationData.userLocation?.latitude ?? communityLocation.value.lat
})
onError((err) => {
@ -79,6 +80,7 @@ const saveUserLocation = async () => {
},
})
toastSuccess(t('settings.GMS.location.updateSuccess'))
userLocation.value = capturedLocation.value
isModalOpen.value = false
} catch (error) {
toastError(error.message)

View File

@ -1,6 +1,7 @@
<template>
<div>
<coordinates-display
v-if="map"
:community-position="communityPosition"
:user-position="userPosition"
@centerMap="handleMapCenter"
@ -21,7 +22,6 @@ const mapContainer = ref(null)
const map = ref(null)
const userMarker = ref(null)
const communityMarker = ref(null)
const searchQuery = ref('')
const userPosition = ref({ lat: 0, lng: 0 })
const communityPosition = ref({ lat: 0, lng: 0 })
const defaultZoom = 13
@ -42,8 +42,7 @@ onMounted(async () => {
if (props.communityMarkerCoords) {
communityPosition.value = props.communityMarkerCoords
}
await nextTick()
initMap()
setTimeout(() => initMap(), 250)
window.addEventListener('resize', handleResize)
})
@ -60,6 +59,7 @@ function initMap() {
center: [userPosition.value.lat, userPosition.value.lng],
zoom: defaultZoom,
zoomControl: false,
closePopupOnClick: false,
})
L.control.zoom({ position: 'topleft' }).addTo(map.value)
@ -72,6 +72,7 @@ function initMap() {
// User marker (movable)
userMarker.value = L.marker([userPosition.value.lat, userPosition.value.lng], {
draggable: true,
interactive: false,
icon: L.icon({
iconUrl:
'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-blue.png',
@ -83,11 +84,18 @@ function initMap() {
}),
}).addTo(map.value)
userMarker.value.bindPopup(t('settings.GMS.map.userLocationLabel')).openPopup()
userMarker.value
.bindPopup(t('settings.GMS.map.userLocationLabel'), {
autoClose: false,
closeOnClick: false,
closeButton: false,
})
.openPopup()
// Community marker (fixed)
communityMarker.value = L.marker([communityPosition.value.lat, communityPosition.value.lng], {
draggable: false,
interactive: false,
icon: L.icon({
iconUrl:
'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
@ -99,7 +107,13 @@ function initMap() {
}),
}).addTo(map.value)
communityMarker.value.bindPopup(t('settings.GMS.map.communityLocationLabel'))
communityMarker.value
.bindPopup(t('settings.GMS.map.communityLocationLabel'), {
autoClose: false,
closeOnClick: false,
closeButton: false,
})
.openPopup()
map.value.on('click', onMapClick)
userMarker.value.on('dragend', onMarkerDragEnd)

View File

@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { ref } from 'vue'
import UserName from './UserName.vue'
import { createStore } from 'vuex'
import { createI18n } from 'vue-i18n'
@ -60,20 +61,22 @@ vi.mock('@/composables/useToast', () => ({
}),
}))
const valuesMock = {}
const errorsMock = {}
// Updated to use Vue's reactivity
const valuesMock = ref({ username: '' })
const errorsMock = ref({})
const setFieldValueMock = vi.fn((field, value) => {
valuesMock[field] = value
valuesMock.value[field] = value
})
const handleSubmitMock = vi.fn((callback) => {
return () => callback(valuesMock)
return () => callback(valuesMock.value)
})
vi.mock('vee-validate', () => ({
useForm: () => ({
handleSubmit: handleSubmitMock,
setFieldValue: setFieldValueMock,
values: valuesMock,
errors: errorsMock,
values: valuesMock.value,
errors: errorsMock.value,
}),
}))
@ -94,13 +97,13 @@ describe('UserName Form', () => {
beforeEach(() => {
vi.clearAllMocks()
valuesMock.username = ''
valuesMock.value.username = ''
wrapper = mountComponent()
})
describe('when no username is set', () => {
it('renders the component', () => {
expect(wrapper.find('div#username_form').exists()).toBe(true)
expect(wrapper.find('div#username-form').exists()).toBe(true)
})
it('displays the no-username alert', () => {
@ -132,8 +135,13 @@ describe('UserName Form', () => {
})
it('enables submit button when a new username is entered', async () => {
setFieldValueMock('username', 'newUser')
valuesMock.value.username = 'newUser' // Directly set the reactive value
await wrapper.vm.$nextTick()
// Trigger input change to ensure reactivity
await wrapper.find('[data-test="component-input-username"]').trigger('input')
await wrapper.vm.$nextTick()
expect(wrapper.find('[data-test="submit-username-button"]').exists()).toBe(true)
expect(
wrapper.find('[data-test="submit-username-button"]').attributes('disabled'),
@ -143,7 +151,8 @@ describe('UserName Form', () => {
it('submits the form and updates the store on success', async () => {
mutationMock.mockResolvedValue({ data: { updateUserInfos: { validValues: 3 } } })
setFieldValueMock('username', 'newUser')
valuesMock.value.username = 'newUser'
await wrapper.vm.$nextTick()
await wrapper.find('form').trigger('submit')
expect(mutationMock).toHaveBeenCalledWith({ alias: 'newUser' })
@ -154,7 +163,8 @@ describe('UserName Form', () => {
it('shows an error toast on submission failure', async () => {
mutationMock.mockRejectedValue(new Error('API Error'))
setFieldValueMock('username', 'newUser')
valuesMock.value.username = 'newUser'
await wrapper.vm.$nextTick()
await wrapper.find('form').trigger('submit')
expect(mutationMock).toHaveBeenCalledWith({ alias: 'newUser' })

View File

@ -1,5 +1,5 @@
<template>
<div id="username_form">
<div id="username-form">
<div v-if="store.state.username">
<label>{{ $t('form.username') }}</label>
<BFormGroup
@ -21,16 +21,13 @@
<BRow class="mb-3">
<BCol class="col-12">
<input-username
:model-value="username"
name="username"
:placeholder="$t('form.username-placeholder')"
show-all-errors
:unique="true"
:rules="rules"
:is-edit="isEdit"
data-test="component-input-username"
@set-is-edit="setIsEdit(true)"
@update:model-value="username = $event"
:initial-username-value="username"
/>
</BCol>
<BCol class="col-12">
@ -64,7 +61,7 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { computed } from 'vue'
import { useStore } from 'vuex'
import { useMutation } from '@vue/apollo-composable'
import { useI18n } from 'vue-i18n'
@ -78,9 +75,6 @@ const store = useStore()
const { toastError, toastSuccess } = useAppToast()
const { t } = useI18n()
const isEdit = ref(false)
const username = ref(store.state.username || '')
const usernameUnique = ref(false)
const rules = {
required: true,
min: 3,
@ -103,12 +97,9 @@ const onSubmit = handleSubmit(async () => {
}
})
const setIsEdit = (bool) => {
username.value = store.state.username
isEdit.value = bool
}
const username = computed(() => store.state.username || '')
const newUsername = computed(() => username.value !== store.state.username)
const newUsername = computed(() => values.username && values.username !== store.state.username)
const disabled = (err) => {
return !newUsername.value || !!Object.keys(err).length

View File

@ -1,7 +1,6 @@
<template>
<div class="user-naming-format">
<BDropdown v-model="selectedOption">
<template #button-content>{{ selectedOptionLabel }}</template>
<BDropdown :text="selectedOptionLabel">
<BDropdownItem
v-for="option in dropdownOptions"
:key="option.value"

View File

@ -1,9 +1,9 @@
import { useI18n } from 'vue-i18n'
import { useToast } from 'bootstrap-vue-next'
import { useToastController } from 'bootstrap-vue-next'
export function useAppToast() {
const { t } = useI18n()
const { show } = useToast()
const { show } = useToastController()
const toastSuccess = (message) => {
toast(message, {
title: t('success'),

View File

@ -8,18 +8,39 @@ const constants = {
DECAY_START_TIME: new Date('2021-05-13 17:46:31-0000'), // GMT+0
CONFIG_VERSION: {
DEFAULT: 'DEFAULT',
EXPECTED: 'v6.2024-02-27',
EXPECTED: 'v7.2024-08-06',
CURRENT: '',
},
}
const version = {
FRONTEND_MODULE_PROTOCOL: process.env.FRONTEND_MODULE_PROTOCOL ?? 'http',
FRONTEND_MODULE_HOST: process.env.FRONTEND_MODULE_HOST ?? '0.0.0.0',
FRONTEND_MODULE_PORT: process.env.FRONTEND_MODULE_PORT ?? '3000',
APP_VERSION: pkg.version,
BUILD_COMMIT: process.env.BUILD_COMMIT ?? null,
// self reference of `version.BUILD_COMMIT` is not possible at this point, hence the duplicate code
BUILD_COMMIT_SHORT: (process.env.BUILD_COMMIT ?? '0000000').slice(0, 7),
}
let FRONTEND_MODULE_URL
// in case of hosting the frontend module with a nodejs-instance
if (process.env.FRONTEND_HOSTING === 'nodejs') {
FRONTEND_MODULE_URL =
version.FRONTEND_MODULE_PROTOCOL +
'://' +
version.FRONTEND_MODULE_HOST +
':' +
version.FRONTEND_MODULE_PORT
} else {
// in case of hosting the frontend module with a nginx
FRONTEND_MODULE_URL = version.FRONTEND_MODULE_PROTOCOL + '://' + version.FRONTEND_MODULE_HOST
}
// const FRONTEND_MODULE_URI = version.FRONTEND_MODULE_PROTOCOL + '://' + version.FRONTEND_MODULE_HOST // +
// ':' +
// version.FRONTEND_MODULE_PORT
const features = {
GMS_ACTIVE: process.env.GMS_ACTIVE ?? false,
HUMHUB_ACTIVE: process.env.HUMHUB_ACTIVE ?? false,
@ -30,16 +51,16 @@ const environment = {
DEBUG: process.env.NODE_ENV !== 'production' ?? false,
PRODUCTION: process.env.NODE_ENV === 'production' ?? false,
DEFAULT_PUBLISHER_ID: process.env.DEFAULT_PUBLISHER_ID ?? 2896,
PORT: process.env.PORT ?? 3000,
}
const COMMUNITY_HOST = process.env.COMMUNITY_HOST ?? 'localhost'
const URL_PROTOCOL = process.env.URL_PROTOCOL ?? 'http'
const COMMUNITY_URL = process.env.COMMUNITY_URL ?? `${URL_PROTOCOL}://${COMMUNITY_HOST}`
// const COMMUNITY_HOST = process.env.COMMUNITY_HOST ?? 'localhost'
// const URL_PROTOCOL = process.env.URL_PROTOCOL ?? 'http'
const COMMUNITY_URL = process.env.COMMUNITY_URL ?? FRONTEND_MODULE_URL
const endpoints = {
GRAPHQL_URI: COMMUNITY_URL + (process.env.GRAPHQL_PATH ?? '/graphql'),
GRAPHQL_URI: process.env.GRAPHQL_URI ?? COMMUNITY_URL + (process.env.GRAPHQL_PATH ?? '/graphql'),
ADMIN_AUTH_URL:
process.env.ADMIN_AUTH_URL ??
COMMUNITY_URL + (process.env.ADMIN_AUTH_PATH ?? '/admin/authenticate?token={token}'),
}
@ -94,4 +115,4 @@ const CONFIG = {
...meta,
}
export default CONFIG
module.exports = CONFIG

View File

@ -137,7 +137,13 @@ const computedKeyFromForm = computed(() => {
return `${form.value.id}_${form.value.date}_${form.value.memo}_${form.value.amount}_${form.value.hours}`
})
const { onResult: onOpenCreationsResult, refetch: refetchOpenCreations } = useQuery(openCreations)
const { onResult: onOpenCreationsResult, refetch: refetchOpenCreations } = useQuery(
openCreations,
() => ({}),
{
fetchPolicy: 'network-only',
},
)
const { onResult: onListAllContributionsResult, refetch: refetchAllContributions } = useQuery(
listAllContributions,
() => ({

View File

@ -130,18 +130,32 @@ describe('Register', () => {
})
it('displays a message that firstname is required', async () => {
// First set some value to make the field dirty
await wrapper.find('#registerFirstname').setValue('test')
await wrapper.find('#registerFirstname').trigger('blur')
// Then clear it to trigger validation
await wrapper.find('#registerFirstname').setValue('')
await wrapper.find('#registerFirstname').trigger('blur')
await flushPromises()
expect(wrapper.find('#registerFirstnameLiveFeedback').exists()).toBe(true)
expect(wrapper.find('#registerFirstnameLiveFeedback').text()).toBe(
'The field firstname is invalid',
)
})
it('displays a message that lastname is required', async () => {
// First set some value to make the field dirty
await wrapper.find('#registerLastname').setValue('test')
await wrapper.find('#registerLastname').trigger('blur')
// Then clear it to trigger validation
await wrapper.find('#registerLastname').setValue('')
await wrapper.find('#registerLastname').trigger('blur')
await flushPromises()
expect(wrapper.find('#registerLastnameLiveFeedback').exists()).toBe(true)
expect(wrapper.find('#registerLastnameLiveFeedback').text()).toBe(
'The field lastname is invalid',
)

View File

@ -94,7 +94,7 @@
</BCol>
<BCol cols="12" md="6" lg="6" class="text-end">
<user-settings-switch
:initial-value="$store.state.humhubAllowed"
:initial-value="state.humhubAllowed"
:attr-name="'humhubAllowed'"
:disabled="isHumhubActivated"
:enabled-text="$t('settings.humhub.enabled')"
@ -111,7 +111,7 @@
</BCol>
<BCol cols="12" md="6" lg="6">
<user-naming-format
:initial-value="$store.state.humhubPublishName"
:initial-value="state.humhubPublishName"
:attr-name="'humhubPublishName'"
:success-message="$t('settings.humhub.publish-name.updated')"
/>
@ -125,7 +125,7 @@
</BCol>
<BCol cols="12" md="6" lg="6" class="text-end">
<user-settings-switch
:initial-value="$store.state.gmsAllowed"
:initial-value="state.gmsAllowed"
:attr-name="'gmsAllowed'"
:enabled-text="$t('settings.GMS.enabled')"
:disabled-text="$t('settings.GMS.disabled')"
@ -141,7 +141,7 @@
</BCol>
<BCol cols="12" md="6" lg="6">
<user-naming-format
:initial-value="$store.state.gmsPublishName"
:initial-value="state.gmsPublishName"
:attr-name="'gmsPublishName'"
:success-message="$t('settings.GMS.publish-name.updated')"
/>

View File

@ -9,18 +9,34 @@ import EnvironmentPlugin from 'vite-plugin-environment'
import { createHtmlPlugin } from 'vite-plugin-html'
import { BootstrapVueNextResolver } from 'bootstrap-vue-next'
import CONFIG from './src/config'
import dotenv from 'dotenv'
dotenv.config() // load env vars from .env
const CONFIG = require('./src/config')
// https://vitejs.dev/config/
export default defineConfig({
server: {
host: '0.0.0.0',
port: 3000,
host: CONFIG.FRONTEND_MODULE_HOST, // '0.0.0.0',
port: CONFIG.FRONTEND_MODULE_PORT, // 3000,
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
assets: path.join(__dirname, 'src/assets'),
'@vee-validate/i18n/dist/locale/en.json':
'/node_modules/@vee-validate/i18n/dist/locale/en.json',
'@vee-validate/i18n/dist/locale/de.json':
'/node_modules/@vee-validate/i18n/dist/locale/de.json',
'@vee-validate/i18n/dist/locale/es.json':
'/node_modules/@vee-validate/i18n/dist/locale/es.json',
'@vee-validate/i18n/dist/locale/fr.json':
'/node_modules/@vee-validate/i18n/dist/locale/fr.json',
'@vee-validate/i18n/dist/locale/nl.json':
'/node_modules/@vee-validate/i18n/dist/locale/nl.json',
'@vee-validate/i18n/dist/locale/tr.json':
'/node_modules/@vee-validate/i18n/dist/locale/tr.json',
},
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json', '.vue'],
},
@ -60,9 +76,12 @@ export default defineConfig({
URL_PROTOCOL: null,
COMMUNITY_URL: null,
GRAPHQL_PATH: null,
ADMIN_AUTH_PATH: null,
GRAPHQL_URI: CONFIG.GRAPHQL_URI, // null,
ADMIN_AUTH_PATH: CONFIG.ADMIN_AUTH_PATH ?? null, // it is the only env without exported default
ADMIN_AUTH_URL: CONFIG.ADMIN_AUTH_URL, // null,
COMMUNITY_NAME: null,
COMMUNITY_REGISTER_PATH: null,
COMMUNITY_REGISTER_URL: null,
COMMUNITY_DESCRIPTION: null,
COMMUNITY_SUPPORT_MAIL: null,
META_URL: null,
@ -87,5 +106,6 @@ export default defineConfig({
},
build: {
outDir: path.resolve(__dirname, './build'),
chunkSizeWarningLimit: 1600,
},
})

File diff suppressed because it is too large Load Diff