Compare commits

..

120 Commits

Author SHA1 Message Date
Alex
9d25fa005c gorm model fixes 2025-05-20 12:41:21 +02:00
Alex
20a693a80c Setting Consents userid null upon deletion 2025-05-14 18:25:53 +02:00
Alex
242d37713d unscoped delete 2025-05-14 15:36:20 +02:00
Alex
4b83be6ed8 templates and constraints 2025-05-14 15:27:10 +02:00
Alex
8fcb73f24d logging 2025-05-14 12:36:46 +02:00
Alex
11740cb503 fixes in prod 2025-05-14 12:29:47 +02:00
Alex
06f8078b17 chg: mail templates 2025-05-14 12:13:12 +02:00
Alex
18f5dadb06 wip 2025-04-10 15:40:22 +02:00
Alex
87f08dd3be subscription_model -> subscription 2025-03-24 18:00:57 +01:00
Alex
2af4575ff2 new db management 2025-03-24 17:46:25 +01:00
Alex
560623788a refactor 2025-03-24 17:46:11 +01:00
Alex
5d55f5a8d9 add new db constraints and foreignKey mode 2025-03-24 17:45:55 +01:00
Alex
28dfe7ecde refactoring 2025-03-24 17:45:33 +01:00
Alex
741145b960 added Opponent 2025-03-24 17:44:45 +01:00
Alex
490b29295f subscription_validation 2025-03-15 00:14:51 +01:00
Alex
ded5e6ceb1 tests 2025-03-15 00:14:31 +01:00
Alex
b81804439e moved to flat json handling 2025-03-15 00:14:21 +01:00
Alex
9a9af9f002 returning safeUsers now 2025-03-15 00:13:53 +01:00
Alex
cd495584b0 model work 2025-03-15 00:13:23 +01:00
Alex
bbead3c43b backend errors typo 2025-03-15 00:12:59 +01:00
Alex
ce18324391 backend: add car 2025-03-15 00:12:46 +01:00
Alex
c9d5a88dbf frontend: add car handling 2025-03-15 00:12:00 +01:00
Alex
380fee09c1 add: carEditForm 2025-03-15 00:11:41 +01:00
Alex
e35524132e add: car processing 2025-03-15 00:11:27 +01:00
Alex
af000aa4bc add: car date handline 2025-03-15 00:11:10 +01:00
Alex
90ed0925ca new defaults (car) 2025-03-15 00:10:47 +01:00
Alex
7c0a6fedb5 locale 2025-03-15 00:10:25 +01:00
Alex
0e6edc8e65 moved to default subscription in editform 2025-03-15 00:10:16 +01:00
Alex
45a219625a mod: supporter summary 2025-03-12 17:14:20 +01:00
Alex
ee10389f1d changed supporter default to passiv 2025-03-12 17:13:44 +01:00
Alex
073d353764 tests 2025-03-11 20:52:54 +01:00
Alex
9d2b33f832 adapted new user model. 2025-03-11 20:52:39 +01:00
Alex
e60aaa1d69 refactored auth.go & tests 2025-03-11 20:52:11 +01:00
Alex
ca99e28433 removed logging in csp 2025-03-11 20:51:54 +01:00
Alex
9427492cb1 removed logging in headers 2025-03-11 20:51:44 +01:00
Alex
60d3f075bf naming 2025-03-11 20:51:28 +01:00
Alex
d473aef3a9 refactored user service for new model repo style 2025-03-11 20:51:05 +01:00
Alex
ef4d3c9576 add userid to drivers licence model 2025-03-11 20:50:34 +01:00
Alex
9a8b386931 cleaned verification model 2025-03-11 20:50:19 +01:00
Alex
9c429185dc add route for new mail verification 2025-03-11 20:47:57 +01:00
Alex
c7865d0582 add user id to mail verification url 2025-03-11 20:47:31 +01:00
Alex
feb8abcc42 refactored validation 2025-03-11 20:46:45 +01:00
Alex
c8d0904fd7 del obsolete repos and services 2025-03-11 20:46:24 +01:00
Alex
294ad76e4b first step to remove global database.db 2025-03-11 20:45:49 +01:00
Alex
ca441d51e7 moved repo to user model 2025-03-11 20:44:29 +01:00
Alex
39c060794a del obsolete handleVerifyUserError 2025-03-11 20:43:42 +01:00
Alex
c6ea179eca moved field validation to validation package 2025-03-11 20:42:45 +01:00
Alex
0d6013d566 add new errors 2025-03-11 20:42:05 +01:00
Alex
0c3204df15 fix: moved to licenceServiceInterface 2025-03-11 20:39:45 +01:00
Alex
cfc10ab087 moved generateRandomString to local config class 2025-03-11 20:39:12 +01:00
Alex
df6125b7cb locale 2025-03-11 20:30:58 +01:00
Alex
7af66ee9de added user_password tests 2025-03-05 14:54:19 +01:00
Alex
b2b702c21d readonly fields changed 2025-03-05 10:54:03 +01:00
Alex
fa996692fe inputfield: readonly is more prominent 2025-03-04 10:04:06 +01:00
Alex
8258a7c2a3 missing base var 2025-03-03 18:17:52 +01:00
Alex
37ccbaaba4 added redirect logging 2025-03-03 18:14:46 +01:00
Alex
c810e48451 Add: CreateBackendAccess function 2025-03-03 17:52:19 +01:00
Alex
8f737282f2 disabled logging 2025-03-03 17:51:38 +01:00
Alex
3756205ad4 locale 2025-03-03 17:51:24 +01:00
Alex
68851c6257 frontend: DO IT THE SVELTE WAY 2025-03-03 17:51:12 +01:00
Alex
8d56a9ad48 typo 2025-03-03 14:01:24 +01:00
Alex
3d349a709c add frontend_path 2025-03-03 14:00:31 +01:00
Alex
d1d5d839ae typo 2025-03-03 14:00:07 +01:00
Alex
8ec9fb247f hardened password validation, added tests 2025-03-03 12:33:07 +01:00
Alex
f5df70fba8 fixing licence missing edge case 2025-03-02 23:57:52 +01:00
Alex
60a12c97be removed password logging 2025-03-02 23:28:54 +01:00
Alex
bb56d1f7c7 fix: login redirect 2025-03-02 23:28:45 +01:00
Alex
f719a0bbf5 Birthday not required when supporter 2025-03-02 23:14:59 +01:00
Alex
05d94ae09c changed verify url 2025-03-02 23:14:38 +01:00
Alex
ff3106b8be backend: fixed wrong error codes 2025-03-02 23:14:03 +01:00
Alex
6937ab333c frontend redirects 2025-03-02 23:12:29 +01:00
Alex
1f61c9ad71 frontend: add defaultUser, supporter 2025-03-02 23:12:10 +01:00
Alex
29f405385e implemented permission system 2025-03-02 10:27:56 +01:00
Alex
298ef9843e frontend permission system 2025-03-02 10:27:16 +01:00
Alex
aa1bd00e80 add en locale 2025-03-02 10:26:53 +01:00
Alex
ac0b7234d4 locale &update handling 2025-03-01 15:29:34 +01:00
Alex
c28354ed2d added missing ! to hasprivilige lol 2025-03-01 14:40:00 +01:00
Alex
2342ce24de added missing redirects 2025-03-01 13:30:06 +01:00
Alex
c6be9d2302 logging 2025-03-01 12:40:28 +01:00
Alex
f00e0fa758 logging 2025-03-01 12:26:28 +01:00
Alex
1d57b8e8e8 changed dev and production base url 2025-03-01 12:12:40 +01:00
Alex
7216a48f4e added paths to svelte.config 2025-03-01 11:42:50 +01:00
Alex
f4e57e7558 moved to production in Dockerfile 2025-03-01 11:40:49 +01:00
Alex
770ef34c22 fix build path docker 2025-03-01 11:13:27 +01:00
Alex
64310282ca Dockerfile expose 2025-03-01 11:11:15 +01:00
Alex
91787b616e finalized routes 2025-03-01 09:56:38 +01:00
Alex
309e3a9d1e dockerized frontend 2025-03-01 09:37:01 +01:00
Alex
903cd6df28 config logging. compose.yml 2025-03-01 09:08:48 +01:00
Alex
0ba938be21 backend: add: licencecontroller_test 2025-02-28 12:26:13 +01:00
Alex
ef98745732 improved error handling 2025-02-28 11:57:06 +01:00
Alex
e3ebbe596c errors: handling, locale 2025-02-28 11:56:53 +01:00
Alex
658cc9aecd changed privilige handling 2025-02-28 11:56:26 +01:00
Alex
a2e8abbf6b error handling 2025-02-28 10:46:40 +01:00
Alex
b0271f8443 Cors test 2025-02-28 10:08:42 +01:00
Alex
e553c2dc2e del: logging 2025-02-28 10:05:35 +01:00
Alex
20754b4422 backend: membership errorhandling tests 2025-02-28 10:05:25 +01:00
Alex
386b50e857 backend: membershipController error handling overhaul 2025-02-28 09:46:02 +01:00
Alex
34cf3a1e33 new errors 2025-02-28 09:45:41 +01:00
Alex
d9605fde58 merge 2025-02-28 08:56:16 +01:00
Alex
9c9430ca9c frontend: disabled button while processing password reset 2025-02-28 08:54:10 +01:00
Alex
2ffd1f439f backend moved to separate directory
backend: deleted the old structure
2025-02-28 08:53:14 +01:00
Alex
ad599ae3f4 frontend: disabled button while processing password reset 2025-02-28 08:51:35 +01:00
Alex
8137f121ed backend: moved setpassword to user model 2025-02-27 12:13:50 +01:00
Alex
82558edd5a removed comment 2025-02-27 12:13:27 +01:00
Alex
421b4753e5 backend fix mail recipients 2025-02-27 12:13:12 +01:00
Alex
e0717ec09a backend: status adjustments 2025-02-27 12:12:42 +01:00
Alex
d355c6906e tests 2025-02-27 12:12:12 +01:00
Alex
c42adc858f added password reset system 2025-02-26 21:45:16 +01:00
Alex
7c01b77445 backend: moved to correct plaintext mail parsing 2025-02-26 21:44:24 +01:00
Alex
a2886fc1e0 backend changed verification model 2025-02-26 21:42:49 +01:00
Alex
6b408d64a7 frontend: made message i18n compliant 2025-02-26 21:41:34 +01:00
Alex
3eeb35c768 frontend: fix missing usereditform role_id info 2025-02-26 21:41:00 +01:00
Alex
b7682f8dc3 moved to put from patch 2025-02-26 21:40:32 +01:00
Alex
dde3b3d47b frontend: fix validation 2025-02-26 21:39:42 +01:00
Alex
c607622185 nomenclature
nomenclature
2025-02-26 21:39:14 +01:00
Alex
2866917aef locale 2025-02-26 21:36:57 +01:00
Alex
f55ef5cf70 backend added struct merging and FieldPermissionsOnRoleId 2025-02-23 12:29:12 +01:00
Alex
577e0fe2f7 corrected redirects 2025-02-23 12:27:43 +01:00
Alex
c23a6a6b5f del logging 2025-02-23 12:27:27 +01:00
Alex
c34f9c28a5 locale 2025-02-23 12:26:58 +01:00
150 changed files with 7991 additions and 3064 deletions

1
.gitignore vendored
View File

@@ -40,7 +40,6 @@ go.work
!go.sum !go.sum
!go.mod !go.mod
#!*.sql #!*.sql
!README.md !README.md
!LICENSE !LICENSE

View File

@@ -1,10 +0,0 @@
services:
app:
build: .
container_name: carsharingBackend
ports:
- "8080:8080"
volumes:
- ./configs/config.json:/root/configs/config.json:ro
- ./data/db.sqlite3:/root/data/db.sqlite3
- ./templates:/root/templates:ro

View File

@@ -1,2 +1,2 @@
VITE_BASE_API_URI_DEV=http://127.0.0.1:8080 VITE_BASE_API_URI_DEV=http://127.0.0.1:8080/api
VITE_BASE_API_URI_PROD= VITE_BASE_API_URI_PROD=

14
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:23-alpine AS deploy-node
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
ENV NODE_ENV=production
RUN npm run build
EXPOSE 3000
CMD ["node", "build/index.js"]

View File

@@ -15,6 +15,8 @@
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@playwright/test": "^1.49.1", "@playwright/test": "^1.49.1",
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",
@@ -815,6 +817,126 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/plugin-commonjs": {
"version": "28.0.2",
"resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-28.0.2.tgz",
"integrity": "sha512-BEFI2EDqzl+vA1rl97IDRZ61AIwGH093d9nz8+dThxJNH8oSoB7MjWvPCX3dkaK1/RCJ/1v/R1XB15FuSs0fQw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"commondir": "^1.0.1",
"estree-walker": "^2.0.2",
"fdir": "^6.2.0",
"is-reference": "1.2.1",
"magic-string": "^0.30.3",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=16.0.0 || 14 >= 14.17"
},
"peerDependencies": {
"rollup": "^2.68.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-commonjs/node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/plugin-commonjs/node_modules/is-reference": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz",
"integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "*"
}
},
"node_modules/@rollup/plugin-json": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz",
"integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.1.0"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/plugin-node-resolve": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.0.tgz",
"integrity": "sha512-0FPvAeVUT/zdWoO0jnb/V5BlBsUSNfkIOtFHzMO4H9MOklrmQFY6FduVHKucNb/aTFxvnGhj4MNj/T1oNdDfNg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^5.0.1",
"@types/resolve": "1.20.2",
"deepmerge": "^4.2.2",
"is-module": "^1.0.0",
"resolve": "^1.22.1"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^2.78.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.4.tgz",
"integrity": "sha512-USm05zrsFxYLPdWWq+K3STlWiT/3ELn3RcV5hJMghpeAIhxfsUIg6mt12CBJBInWMV4VneoV7SfGv8xIwo2qNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0",
"estree-walker": "^2.0.2",
"picomatch": "^4.0.2"
},
"engines": {
"node": ">=14.0.0"
},
"peerDependencies": {
"rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@rollup/pluginutils/node_modules/estree-walker": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
"dev": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.32.0", "version": "4.32.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.32.0.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.32.0.tgz",
@@ -1094,6 +1216,32 @@
"@sveltejs/kit": "^2.0.0" "@sveltejs/kit": "^2.0.0"
} }
}, },
"node_modules/@sveltejs/adapter-node": {
"version": "5.2.12",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.2.12.tgz",
"integrity": "sha512-0bp4Yb3jKIEcZWVcJC/L1xXp9zzJS4hDwfb4VITAkfT4OVdkspSHsx7YhqJDbb2hgLl6R9Vs7VQR+fqIVOxPUQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-json": "^6.1.0",
"@rollup/plugin-node-resolve": "^16.0.0",
"rollup": "^4.9.5"
},
"peerDependencies": {
"@sveltejs/kit": "^2.4.0"
}
},
"node_modules/@sveltejs/adapter-static": {
"version": "3.0.8",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.8.tgz",
"integrity": "sha512-YaDrquRpZwfcXbnlDsSrBQNCChVOT9MGuSg+dMAyfsAa1SmiAhrA5jUYUiIMC59G92kIbY/AaQOWcBdq+lh+zg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@sveltejs/kit": "^2.0.0"
}
},
"node_modules/@sveltejs/kit": { "node_modules/@sveltejs/kit": {
"version": "2.16.1", "version": "2.16.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.16.1.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.16.1.tgz",
@@ -1185,6 +1333,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitest/expect": { "node_modules/@vitest/expect": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.4.tgz", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.0.4.tgz",
@@ -1540,6 +1695,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/commondir": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
"integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==",
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": { "node_modules/concat-map": {
"version": "0.0.1", "version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2157,6 +2319,16 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
} }
}, },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2205,6 +2377,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2265,6 +2450,22 @@
"tslib": "2" "tslib": "2"
} }
}, },
"node_modules/is-core-module": {
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-extglob": { "node_modules/is-extglob": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -2288,6 +2489,13 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/is-module": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz",
"integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
"dev": true,
"license": "MIT"
},
"node_modules/is-promise": { "node_modules/is-promise": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz",
@@ -2622,6 +2830,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/path-parse": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/pathe": { "node_modules/pathe": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.2.tgz",
@@ -2646,6 +2861,19 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz",
"integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.50.0", "version": "1.50.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.50.0.tgz",
@@ -2866,6 +3094,27 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
"integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.0",
"path-parse": "^1.0.7",
"supports-preserve-symlinks-flag": "^1.0.0"
},
"bin": {
"resolve": "bin/resolve"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3042,6 +3291,19 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/svelte": { "node_modules/svelte": {
"version": "5.19.3", "version": "5.19.3",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.19.3.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.19.3.tgz",

View File

@@ -21,6 +21,7 @@
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@playwright/test": "^1.49.1", "@playwright/test": "^1.49.1",
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.16.0", "@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
"eslint": "^9.18.0", "eslint": "^9.18.0",

62
frontend/src/app.d.ts vendored
View File

@@ -17,7 +17,7 @@ interface Membership {
start_date: string | ''; start_date: string | '';
end_date: string | ''; end_date: string | '';
parent_member_id: number | -1; parent_member_id: number | -1;
subscription_model: Subscription; subscription: Subscription;
} }
interface BankAccount { interface BankAccount {
@@ -51,7 +51,6 @@ interface User {
last_name: string | ''; last_name: string | '';
password: string | ''; password: string | '';
phone: string | ''; phone: string | '';
notes: string | '';
address: string | ''; address: string | '';
zip_code: string | ''; zip_code: string | '';
city: string | ''; city: string | '';
@@ -60,11 +59,51 @@ interface User {
role_id: number | -1; role_id: number | -1;
dateofbirth: string | ''; dateofbirth: string | '';
company: string | ''; company: string | '';
profile_picture: string | ''; membership: Membership | null;
payment_status: number | -1; bank_account: BankAccount | null;
membership: Membership; licence: Licence | null;
bank_account: BankAccount; notes: string | '';
licence: Licence; }
interface Car {
id: number | -1;
name: string | '';
status: number | 0;
brand: string | '';
model: string | '';
price: number | 0;
rate: number | 0;
start_date: string | '';
end_date: string | '';
color: string | '';
licence_plate: string | '';
location: Location | null;
damages: Damage[] | null;
insurances: Insurance[] | null;
notes: string | '';
}
interface Location {
latitude: number | 0;
longitude: number | 0;
}
interface Damage {
id: number | -1;
name: string | '';
opponent: User | null;
driver_id: number | -1;
insurance: Insurance | null;
date: string | '';
notes: string | '';
}
interface Insurance {
id: number | -1;
company: string | '';
reference: string | '';
start_date: string | '';
end_date: string | '';
notes: string | ''; notes: string | '';
} }
@@ -74,12 +113,21 @@ declare global {
interface Locals { interface Locals {
user: User; user: User;
users: User[]; users: User[];
cars: Cars[];
subscriptions: Subscription[]; subscriptions: Subscription[];
licence_categories: LicenceCategory[]; licence_categories: LicenceCategory[];
} }
interface Types { interface Types {
licenceCategory: LicenceCategory; licenceCategory: LicenceCategory;
subscription: Subscription; subscription: Subscription;
membership: Membership;
licence: Licence;
licenceCategory: LicenceCategory;
bankAccount: BankAccount;
car: Car;
insurance: Insurance;
location: Location;
damage: Damage;
} }
// interface PageData {} // interface PageData {}
// interface Platform {} // interface Platform {}

View File

@@ -3,7 +3,6 @@ import { refreshCookie, userDatesFromRFC3339 } from '$lib/utils/helpers';
/** @type {import('@sveltejs/kit').Handle} */ /** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) { export async function handle({ event, resolve }) {
console.log('Hook started', event.url.pathname);
if (event.locals.user) { if (event.locals.user) {
// if there is already a user in session load page as normal // if there is already a user in session load page as normal
console.log('user is logged in'); console.log('user is logged in');
@@ -17,7 +16,7 @@ export async function handle({ event, resolve }) {
// if there is no jwt load page as normal // if there is no jwt load page as normal
return await resolve(event); return await resolve(event);
} }
const response = await fetch(`${BASE_API_URI}/backend/users/current`, { const response = await fetch(`${BASE_API_URI}/auth/users/current`, {
credentials: 'include', credentials: 'include',
headers: { headers: {
Cookie: `jwt=${jwt}` Cookie: `jwt=${jwt}`
@@ -38,11 +37,11 @@ export async function handle({ event, resolve }) {
userDatesFromRFC3339(data.user); userDatesFromRFC3339(data.user);
const [subscriptionsResponse, licenceCategoriesResponse] = await Promise.all([ const [subscriptionsResponse, licenceCategoriesResponse] = await Promise.all([
fetch(`${BASE_API_URI}/backend/membership/subscriptions`, { fetch(`${BASE_API_URI}/auth/subscriptions`, {
credentials: 'include', credentials: 'include',
headers: { Cookie: `jwt=${jwt}` } headers: { Cookie: `jwt=${jwt}` }
}), }),
fetch(`${BASE_API_URI}/backend/licence/categories`, { fetch(`${BASE_API_URI}/auth/licence/categories`, {
credentials: 'include', credentials: 'include',
headers: { Cookie: `jwt=${jwt}` } headers: { Cookie: `jwt=${jwt}` }
}) })

View File

@@ -0,0 +1,682 @@
<script>
import InputField from '$lib/components/InputField.svelte';
import SmallLoader from '$lib/components/SmallLoader.svelte';
import { createEventDispatcher } from 'svelte';
import { applyAction, enhance } from '$app/forms';
import { hasPrivilige, receive, send } from '$lib/utils/helpers';
import { t } from 'svelte-i18n';
import { defaultDamage, defaultInsurance, defaultOpponent } from '$lib/utils/defaults';
import { PERMISSIONS } from '$lib/utils/constants';
import Modal from './Modal.svelte';
import UserEditForm from './UserEditForm.svelte';
const dispatch = createEventDispatcher();
/** @type {import('../../routes/auth/about/[id]/$types').ActionData} */
export let form;
/** @type {App.Locals['user'] } */
export let editor;
/** @type {App.Locals['users'] } */
export let users;
/** @type {App.Types['car']} */
export let car;
$: console.log(
'damage.opponent changed:',
car?.damages.map((d) => d.opponent)
);
$: console.log(
'damage.insurance changed:',
car?.damages.map((d) => d.insurance)
);
// TODO: Remove when working
// $: if (car.damages.length > 0 && !car.damages.every((d) => d.insurance && d.opponent)) {
// car.damages = car.damages.map((damage) => ({
// ...damage,
// insurance: damage.insurance ?? defaultInsurance(),
// opponent: damage.opponent ?? defaultOpponent()
// }));
// }
let initialized = false; // Prevents infinite loops
// Ensure damages have default values once `car` is loaded
$: if (car && !initialized) {
car = {
...car,
damages:
car.damages?.map((damage) => ({
...damage,
insurance: damage.insurance ?? defaultInsurance(),
opponent: damage.opponent ?? defaultOpponent()
})) || []
};
initialized = true; // Prevents re-running
}
$: isLoading = car === undefined || editor === undefined;
let isUpdating = false;
let readonlyUser = !hasPrivilige(editor, PERMISSIONS.Update);
/** @type {number | null} */
let editingUserIndex = null;
const TABS = ['car.car', 'insurance', 'car.damages'];
let activeTab = TABS[0];
/** @type {import('@sveltejs/kit').SubmitFunction} */
const handleUpdate = async () => {
isUpdating = true;
return async ({ result }) => {
isUpdating = false;
if (result.type === 'success' || result.type === 'redirect') {
dispatch('close');
} else {
document.querySelector('.modal .container')?.scrollTo({ top: 0, behavior: 'smooth' });
}
await applyAction(result);
};
};
</script>
{#if isLoading}
<SmallLoader width={30} message={$t('loading.car_data')} />
{:else if editor && car}
<form class="content" action="?/updateCar" method="POST" use:enhance={handleUpdate}>
<input name="car[id]" type="hidden" bind:value={car.id} />
<h1 class="step-title" style="text-align: center;">
{car.id ? $t('car.edit') : $t('car.create')}
</h1>
{#if form?.errors}
{#each form?.errors as error (error.id)}
<h4
class="step-subtitle warning"
in:receive|global={{ key: error.id }}
out:send|global={{ key: error.id }}
>
{$t(error.field) + ': ' + $t(error.key)}
</h4>
{/each}
{/if}
<div class="button-container">
{#each TABS as tab}
<button
type="button"
class="button-dark"
class:active={activeTab === tab}
on:click={() => (activeTab = tab)}
>
{$t(tab)}
</button>
{/each}
</div>
<div class="tab-content" style="display: {activeTab === 'car.car' ? 'block' : 'none'}">
<InputField
name="car[name]"
label={$t('name')}
bind:value={car.name}
placeholder={$t('placeholder.car_name')}
readonly={readonlyUser}
/>
<InputField
name="car[brand]"
label={$t('car.brand')}
bind:value={car.brand}
placeholder={$t('placeholder.car_brand')}
required={true}
readonly={readonlyUser}
/>
<InputField
name="car[model]"
label={$t('car.model')}
bind:value={car.model}
placeholder={$t('placeholder.car_model')}
required={true}
readonly={readonlyUser}
/>
<InputField
name="car[color]"
label={$t('color')}
bind:value={car.color}
placeholder={$t('placeholder.car_color')}
required={true}
readonly={readonlyUser}
/>
<InputField
name="car[licence_plate]"
label={$t('car.licence_plate')}
bind:value={car.licence_plate}
placeholder={$t('placeholder.car_licence_plate')}
required={true}
toUpperCase={true}
readonly={readonlyUser}
/>
<InputField
name="car[price]"
type="number"
label={$t('price')}
bind:value={car.price}
readonly={readonlyUser}
/>
<InputField
name="car[rate]"
type="number"
label={$t('car.leasing_rate')}
bind:value={car.rate}
readonly={readonlyUser}
/>
<InputField
name="car[start_date]"
type="date"
label={$t('car.start_date')}
bind:value={car.start_date}
readonly={readonlyUser}
/>
<InputField
name="car[end_date]"
type="date"
label={$t('car.end_date')}
bind:value={car.end_date}
readonly={readonlyUser}
/>
<InputField
name="car[notes]"
type="textarea"
label={$t('notes')}
bind:value={car.notes}
placeholder={$t('placeholder.notes', {
values: { name: car.name || car.brand + ' ' + car.model }
})}
rows={10}
/>
</div>
<div class="tab-content" style="display: {activeTab === 'insurance' ? 'block' : 'none'}">
<div class="accordion">
{#each car.insurances as insurance, index}
<input hidden value={insurance?.id} name="car[insurances][{index}][id]" />
<details class="accordion-item" open={index === car.insurances.length - 1}>
<summary class="accordion-header">
{insurance.company ? insurance.company : ''}
{insurance.reference ? ' (' + insurance.reference + ')' : ''}
</summary>
<div class="accordion-content">
<InputField
name="car[insurances][{index}][company]"
label={$t('company')}
bind:value={insurance.company}
placeholder={$t('placeholder.company')}
required={true}
readonly={readonlyUser}
/>
<InputField
name="car[insurances][{index}][reference]"
label={$t('insurance_reference')}
bind:value={insurance.reference}
placeholder={$t('placeholder.insurance_reference')}
required={true}
readonly={readonlyUser}
/>
<InputField
name="car[insurances][{index}][start_date]"
type="date"
label={$t('start')}
bind:value={insurance.start_date}
readonly={readonlyUser}
/>
<InputField
name="car[insurances][{index}][end_date]"
type="date"
label={$t('end')}
bind:value={insurance.end_date}
readonly={readonlyUser}
/>
<InputField
name="car[insurances][{index}][notes]"
type="textarea"
label={$t('notes')}
bind:value={insurance.notes}
placeholder={$t('placeholder.notes', {
values: { name: insurance.company || '' }
})}
rows={10}
/>
{#if hasPrivilige(editor, PERMISSIONS.Delete)}
<button
type="button"
class="btn btn-delete danger"
on:click={() => {
if (
confirm(
$t('dialog.insurance_deletion', {
values: {
name: insurance.company + ' (' + insurance.reference + ')'
}
})
)
) {
car.insurances = car.insurances.filter((_, i) => i !== index);
}
}}
>
<i class="fas fa-trash"></i>
{$t('delete')}
</button>
{/if}
</div>
</details>
{/each}
</div>
<div class="button-group">
{#if hasPrivilige(editor, PERMISSIONS.Create)}
<button
type="button"
class="btn primary"
on:click={() => {
car.insurances = [...car.insurances, defaultInsurance()];
}}
>
<i class="fas fa-plus"></i>
{$t('add_new')}
</button>
{/if}
</div>
</div>
<div class="tab-content" style="display: {activeTab === 'car.damages' ? 'block' : 'none'}">
<div class="accordion">
{#each car.damages as damage, index (damage.id)}
<input type="hidden" name="car[damages][{index}][id]" value={damage.id} />
<details class="accordion-item" open={index === car.damages.length - 1}>
<summary class="accordion-header">
<span class="nav-badge">
{damage.name} -
{damage.opponent.first_name}
{damage.opponent.last_name}
</span>
</summary>
<div class="accordion-content">
<InputField
name="car[damages][{index}][date]"
type="date"
label={$t('date')}
bind:value={damage.date}
readonly={readonlyUser}
/>
<InputField
name="car[damages][{index}][name]"
label={$t('car.damages')}
bind:value={damage.name}
required={true}
readonly={readonlyUser}
/>
<InputField
name="car[damages][{index}][driver_id]"
type="select"
label={$t('user.member')}
options={users
?.filter((u) => u.role_id > 0)
.map((u) => ({
value: u.id,
label: `${u.first_name} ${u.last_name}`,
color: '--subtext1'
})) || []}
bind:value={damage.driver_id}
readonly={readonlyUser}
/>
<h4>{$t('user.opponent')}</h4>
<input
hidden
name={`car[damages][${index}][opponent][id]`}
value={car.damages[index].opponent.id}
/>
<input
hidden
name={`car[damages][${index}][opponent][email]`}
value={car.damages[index].opponent.email}
/>
<input
hidden
name={`car[damages][${index}][opponent][first_name]`}
value={car.damages[index].opponent.first_name}
/>
<input
hidden
name={`car[damages][${index}][opponent][last_name]`}
value={damage.opponent.last_name}
/>
<input
hidden
name={`car[damages][${index}][opponent][phone]`}
value={damage.opponent.phone}
/>
<input
hidden
name={`car[damages][${index}][opponent][address]`}
value={damage.opponent.address}
/>
<input
hidden
name={`car[damages][${index}][opponent][city]`}
value={damage.opponent.city}
/>
<input
hidden
name={`car[damages][${index}][opponent][zip_code]`}
value={damage.opponent.zip_code}
/>
<input
hidden
name={`car[damages][${index}][opponent][notes]`}
value={damage.opponent.notes}
/>
<input
hidden
name={`car[damages][${index}][opponent][role_id]`}
value={damage.opponent.role_id}
/>
<input
hidden
name={`car[damages][${index}][opponent][status]`}
value={damage.opponent.status}
/>
<input
hidden
name={`car[damages][${index}][opponent][dateofbirth]`}
value={damage.opponent.dateofbirth}
/>
<input
hidden
name={`car[damages][${index}][opponent][company]`}
value={damage.opponent.company}
/>
<input
hidden
name={`car[damages][${index}][opponent][bank_account][id]`}
value={damage.opponent.bank_account.id}
/>
<input
hidden
name={`car[damages][${index}][opponent][bank_account][mandate_date_signed]`}
value={damage.opponent.bank_account.mandate_date_signed}
/>
<input
hidden
name={`car[damages][${index}][opponent][bank_account][bank]`}
value={damage.opponent.bank_account.bank}
/>
<input
hidden
name={`car[damages][${index}][opponent][bank_account][account_holder_name]`}
value={damage.opponent.bank_account.account_holder_name}
/>
<input
hidden
name={`car[damages][${index}][opponent][bank_account][iban]`}
value={damage.opponent.bank_account.iban}
/>
<input
hidden
name={`car[damages][${index}][opponent][bank_account][bic]`}
value={damage.opponent.bank_account.bic}
/>
<input
hidden
name={`car[damages][${index}][opponent][bank_account][mandate_reference]`}
value={damage.opponent.bank_account.mandate_reference}
/>
<details class="accordion-item">
<summary class="accordion-header">
<span class="nav-badge">
{#if damage.opponent?.first_name}
{damage.opponent.first_name} {damage.opponent.last_name}
{:else}
{$t('not_set')}
{/if}
</span>
</summary>
<div class="accordion-content">
<table class="table">
<tbody>
<tr>
<th>{$t('email')}</th>
<td>{damage.opponent?.email || '-'}</td>
</tr>
<tr>
<th>{$t('phone')}</th>
<td>{damage.opponent?.phone || '-'}</td>
</tr>
<tr>
<th>{$t('address')}</th>
<td>{damage.opponent?.address || '-'}</td>
</tr>
<tr>
<th>{$t('city')}</th>
<td>{damage.opponent?.city || '-'}</td>
</tr>
<tr>
<th>{$t('zip_code')}</th>
<td>{damage.opponent?.zip_code || '-'}</td>
</tr>
</tbody>
</table>
<div class="button-group">
<button
type="button"
class="btn primary"
on:click={() => {
if (!damage.opponent) {
damage.opponent = defaultOpponent();
}
editingUserIndex = index;
}}
>
<i class="fas fa-edit"></i>
{damage.opponent?.id ? $t('edit') : $t('edit')}
</button>
</div>
</div>
</details>
<input
hidden
name={`car[damages][${index}][insurance][id]`}
value={damage.insurance.id}
/>
<input hidden name={`car[damages][${index}][insurance][start_date]`} value="" />
<input hidden name={`car[damages][${index}][insurance][end_date]`} value="" />
<InputField
name="car[damages][{index}][insurance][company]"
label={$t('insurance')}
bind:value={damage.insurance.company}
placeholder={$t('placeholder.company')}
readonly={readonlyUser}
/>
<InputField
name="car[damages][{index}][insurance][reference]"
label={$t('insurance_reference')}
bind:value={damage.insurance.reference}
placeholder={$t('placeholder.insurance_reference')}
readonly={readonlyUser}
/>
<InputField
name="car[damages][{index}][notes]"
type="textarea"
label={$t('notes')}
bind:value={damage.notes}
placeholder={$t('placeholder.notes')}
rows={10}
/>
{#if hasPrivilige(editor, PERMISSIONS.Delete)}
<button
type="button"
class="btn btn-delete danger"
on:click={() => {
if (
confirm(
$t('dialog.damage_deletion', {
values: {
name: damage.name
}
})
)
) {
car.damages = car.damages.filter((_, i) => i !== index);
}
}}
>
<i class="fas fa-trash"></i>
{$t('delete')}
</button>
{/if}
</div>
</details>
{/each}
</div>
{#if hasPrivilige(editor, PERMISSIONS.Create)}
<button
type="button"
class="btn primary"
on:click={() => {
car.damages = [...car.damages, defaultDamage()];
}}
>
<i class="fas fa-plus"></i>
{$t('add_new')}
</button>
{/if}
</div>
<div class="button-container">
{#if isUpdating}
<SmallLoader width={30} message={$t('loading.updating')} />
{:else}
<button type="button" class="button-dark" on:click={() => dispatch('cancel')}>
{$t('cancel')}</button
>
<button type="submit" class="button-dark">{$t('confirm')}</button>
{/if}
</div>
</form>
{/if}
{#if editingUserIndex !== null}
<Modal on:close={close}>
<UserEditForm
{form}
submit_form={false}
subscriptions={null}
licence_categories={null}
{editor}
bind:user={car.damages[editingUserIndex].opponent}
on:cancel={() => (editingUserIndex = null)}
on:close={() => {
car.damages = car.damages;
editingUserIndex = null;
}}
/>
</Modal>
{/if}
<style>
.accordion-item {
border: none;
background: var(--surface0);
margin-bottom: 0.5rem;
border-radius: 8px;
overflow: hidden;
}
.accordion-header {
display: flex;
padding: 1rem;
cursor: pointer;
font-family: 'Roboto Mono', monospace;
color: var(--text);
background: var(--surface1);
transition: background-color 0.2s ease-in-out;
}
.accordion-header:hover {
background: var(--surface2);
}
.accordion-content {
padding: 1rem;
background: var(--surface0);
border-top: 1px solid var(--surface1);
}
.accordion-content .table {
width: 100%;
border-collapse: collapse;
font-family: 'Roboto Mono', monospace;
}
.accordion-content .table th,
.accordion-content .table td {
padding: 0.75rem;
border-bottom: 1px solid #2f2f2f;
text-align: left;
}
.accordion-content .table th {
color: var(--subtext1);
}
.accordion-content .table td {
color: var(--text);
}
.button-container button.active {
background-color: var(--mauve);
border-color: var(--mauve);
color: var(--base);
}
.btn-delete {
margin-left: auto;
}
.tab-content {
padding: 1rem;
border-radius: 0 0 3px 3px;
background-color: var(--surface0);
border: 1px solid var(--surface1);
margin-top: 1rem;
}
.tab-content h4 {
text-align: center;
padding: 0.75rem;
margin: 1rem 0;
color: var(--lavender);
font-family: 'Roboto Mono', monospace;
font-weight: 500;
letter-spacing: 0.5px;
}
.button-container {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
margin-top: 1rem;
width: 100%;
}
.button-container button {
flex: 1 1 0;
min-width: 120px;
max-width: calc(50% - 5px);
background-color: var(--surface1);
color: var(--text);
border: 1px solid var(--overlay0);
transition: all 0.2s ease-in-out;
}
.button-container button:hover {
background-color: var(--surface2);
border-color: var(--lavender);
}
@media (max-width: 480px) {
.button-container button {
flex-basis: 100%;
max-width: none;
}
}
</style>

View File

@@ -7,7 +7,7 @@
<footer class="footer-container"> <footer class="footer-container">
<div class="footer-branding-container"> <div class="footer-branding-container">
<div class="footer-branding"> <div class="footer-branding">
<a class="footer-crafted-by-container" href="https://github.com/Sirneij"> <a class="footer-crafted-by-container" href="https://github.com/17Halbe">
<span>Developed by</span> <span>Developed by</span>
<!-- <img <!-- <img
class="footer-branded-crafted-img" class="footer-branded-crafted-img"
@@ -16,9 +16,7 @@
/> --> /> -->
</a> </a>
<span class="footer-copyright" <span class="footer-copyright">&copy; {year} Alexander Stölting. All Rights Reserved.</span>
>&copy; {year} Alexander Stölting. All Rights Reserved.</span
>
</div> </div>
</div> </div>
</footer> </footer>

View File

@@ -1,9 +1,12 @@
<script> <script>
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { applyAction, enhance } from '$app/forms'; import { applyAction, enhance } from '$app/forms';
import { base } from '$app/paths';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import { PERMISSIONS } from '$lib/utils/constants';
import { hasPrivilige } from '$lib/utils/helpers';
let isMobileMenuOpen = false; let isMobileMenuOpen = false;
@@ -80,11 +83,11 @@
</div> </div>
<div class="header-right" class:mobile-menu-open={isMobileMenuOpen}> <div class="header-right" class:mobile-menu-open={isMobileMenuOpen}>
<div class="header-nav-item" class:active={$page.url.pathname === '/'}> <div class="header-nav-item" class:active={$page.url.pathname === '/'}>
<a href="/">home</a> <a href={`${base}/`}>home</a>
</div> </div>
{#if !$page.data.user} {#if !$page.data.user}
<div class="header-nav-item" class:active={$page.url.pathname === '/auth/login'}> <div class="header-nav-item" class:active={$page.url.pathname === `${base}/auth/login`}>
<a href="/auth/login">login</a> <a href={`${base}/auth/login`}>login</a>
</div> </div>
<!-- <div <!-- <div
class="header-nav-item" class="header-nav-item"
@@ -94,7 +97,7 @@
</div> --> </div> -->
{:else} {:else}
<div class="header-nav-item"> <div class="header-nav-item">
<a href="/auth/about/{$page.data.user.id}"> <a href={`${base}/auth/about/${$page.data.user.id}`}>
<!-- <img <!-- <img
src={$page.data.user.profile_picture ? $page.data.user.profile_picture : Avatar} src={$page.data.user.profile_picture ? $page.data.user.profile_picture : Avatar}
alt={`${$page.data.user.first_name} ${$page.data.user.last_name}`} alt={`${$page.data.user.first_name} ${$page.data.user.last_name}`}
@@ -103,12 +106,12 @@
{$page.data.user.last_name} {$page.data.user.last_name}
</a> </a>
</div> </div>
{#if $page.data.user.role_id > 0} {#if hasPrivilige($page.data.user, PERMISSIONS.View)}
<div <div
class="header-nav-item" class="header-nav-item"
class:active={$page.url.pathname.startsWith('/auth/admin/users')} class:active={$page.url.pathname.startsWith(`${base}/auth/admin/users`)}
> >
<a href="/auth/admin/users">{$t('user.management')}</a> <a href={`${base}/auth/admin/users`}>{$t('user.management')}</a>
</div> </div>
{/if} {/if}
<!-- {#if $page.data.user.is_superuser} <!-- {#if $page.data.user.is_superuser}
@@ -121,7 +124,7 @@
{/if} --> {/if} -->
<form <form
class="header-nav-item" class="header-nav-item"
action="/auth/logout" action={`${base}/auth/logout`}
method="POST" method="POST"
use:enhance={async () => { use:enhance={async () => {
return async ({ result }) => { return async ({ result }) => {

View File

@@ -50,9 +50,9 @@
let inputValue = target.value; let inputValue = target.value;
if (toUpperCase) { if (toUpperCase) {
inputValue = inputValue.toUpperCase(); inputValue = inputValue.toUpperCase();
target.value = inputValue; // Update the input field value
} }
value = inputValue; target.value = inputValue; // Update the input field value
value = inputValue.trim();
} }
} }
@@ -65,15 +65,13 @@
*/ */
function validateField(name, value, required) { function validateField(name, value, required) {
if (value === null || (typeof value === 'string' && !value.trim() && !required)) return null; if (value === null || (typeof value === 'string' && !value.trim() && !required)) return null;
switch (name) { if (name.includes('membership_start_date')) {
case 'membership_start_date':
return typeof value === 'string' && value.trim() ? null : $t('validation.date'); return typeof value === 'string' && value.trim() ? null : $t('validation.date');
case 'email': } else if (name.includes('email')) {
return typeof value === 'string' && /^\S+@\S+\.\S+$/.test(value) return typeof value === 'string' && /^\S+@\S+\.\S+$/.test(value)
? null ? null
: $t('validation.email'); : $t('validation.email');
case 'password': } else if (name.includes('password')) {
case 'password2':
if (typeof value === 'string' && value.length < 8) { if (typeof value === 'string' && value.length < 8) {
return $t('validation.password'); return $t('validation.password');
} }
@@ -81,27 +79,23 @@
return $t('validation.password_match'); return $t('validation.password_match');
} }
return null; return null;
case 'phone': } else if (name.includes('phone')) {
return typeof value === 'string' && /^\+?[0-9\s()-]{7,}$/.test(value) return typeof value === 'string' && /^\+?[0-9\s()-]{7,}$/.test(value)
? null ? null
: $t('validation.phone'); : $t('validation.phone');
case 'zip_code': } else if (name.includes('zip_code')) {
return typeof value === 'string' && /^\d{5}$/.test(value) return typeof value === 'string' && /^\d{5}$/.test(value) ? null : $t('validation.zip_code');
? null } else if (name.includes('iban')) {
: $t('validation.zip_code');
case 'iban':
return typeof value === 'string' && /^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(value) return typeof value === 'string' && /^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(value)
? null ? null
: $t('validation.iban'); : $t('validation.iban');
case 'bic': } else if (name.includes('bic')) {
return typeof value === 'string' && return typeof value === 'string' && /^[A-Z]{6}[A-Z2-9][A-NP-Z0-9]([A-Z0-9]{3})?$/.test(value)
/^[A-Z]{6}[A-Z2-9][A-NP-Z0-9]([A-Z0-9]{3})?$/.test(value)
? null ? null
: $t('validation.bic'); : $t('validation.bic');
case 'licence_number': } else if (name.includes('licence_number')) {
return typeof value === 'string' && value.length == 11 ? null : $t('validation.licence'); return typeof value === 'string' && value.length == 11 ? null : $t('validation.licence');
} else {
default:
return typeof value === 'string' && !value.trim() && required return typeof value === 'string' && !value.trim() && required
? $t('validation.required') ? $t('validation.required')
: null; : null;
@@ -136,7 +130,13 @@
{#if error} {#if error}
<span class="error-message">{error}</span> <span class="error-message">{error}</span>
{/if} {/if}
{#if type === 'select'} {#if readonly}
<input {name} type="hidden" bind:value />
<span class="label"
>{type == 'select' && typeof value === 'number' ? options[value].label : value}</span
>
{:else if type === 'select'}
<select <select
{name} {name}
bind:value bind:value
@@ -179,7 +179,7 @@
<style> <style>
:root { :root {
--form-control-color: var(--green); /* Changed from #6bff55 */ --form-control-color: var(--green); /* Changed from #6bff55 */
--form-control-disabled: var(--overlay0); /* Changed from #959495 */ --form-control-disabled: var(--subtext1); /* Changed from #959495 */
} }
.form-control { .form-control {

View File

@@ -5,6 +5,7 @@
import { applyAction, enhance } from '$app/forms'; import { applyAction, enhance } from '$app/forms';
import { receive, send } from '$lib/utils/helpers'; import { receive, send } from '$lib/utils/helpers';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { defaultSubscription } from '$lib/utils/defaults';
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@@ -17,20 +18,8 @@
/** @type {App.Types['subscription'] | null} */ /** @type {App.Types['subscription'] | null} */
export let subscription; export let subscription;
/** @type {App.Types['subscription']} */
const blankSubscription = {
id: 0,
name: '',
details: '',
conditions: '',
monthly_fee: 0,
hourly_rate: 0,
included_hours_per_year: 0,
included_hours_per_month: 0
};
console.log('Opening subscription modal with:', subscription); console.log('Opening subscription modal with:', subscription);
$: subscription = subscription || { ...blankSubscription }; $: subscription = subscription || { ...defaultSubscription() };
$: isLoading = subscription === undefined || user === undefined; $: isLoading = subscription === undefined || user === undefined;
let isUpdating = false; let isUpdating = false;
@@ -55,7 +44,7 @@
<form class="content" action="?/updateSubscription" method="POST" use:enhance={handleUpdate}> <form class="content" action="?/updateSubscription" method="POST" use:enhance={handleUpdate}>
<input name="susbscription[id]" type="hidden" bind:value={subscription.id} /> <input name="susbscription[id]" type="hidden" bind:value={subscription.id} />
<h1 class="step-title" style="text-align: center;"> <h1 class="step-title" style="text-align: center;">
{subscription.id ? $t('subscription.edit') : $t('subscription.create')} {subscription.id ? $t('subscriptions.edit') : $t('subscriptions.create')}
</h1> </h1>
{#if form?.errors} {#if form?.errors}
{#each form?.errors as error (error.id)} {#each form?.errors as error (error.id)}
@@ -71,7 +60,7 @@
<div class="tab-content" style="display: block"> <div class="tab-content" style="display: block">
<InputField <InputField
name="subscription[name]" name="subscription[name]"
label={$t('subscription.name')} label={$t('subscriptions.name')}
bind:value={subscription.name} bind:value={subscription.name}
placeholder={$t('placeholder.subscription_name')} placeholder={$t('placeholder.subscription_name')}
required={true} required={true}
@@ -88,7 +77,7 @@
<InputField <InputField
name="subscription[conditions]" name="subscription[conditions]"
type="textarea" type="textarea"
label={$t('subscription.conditions')} label={$t('subscriptions.conditions')}
bind:value={subscription.conditions} bind:value={subscription.conditions}
placeholder={$t('placeholder.subscription_conditions')} placeholder={$t('placeholder.subscription_conditions')}
readonly={subscription.id > 0} readonly={subscription.id > 0}
@@ -96,7 +85,7 @@
<InputField <InputField
name="subscription[monthly_fee]" name="subscription[monthly_fee]"
type="number" type="number"
label={$t('subscription.monthly_fee')} label={$t('subscriptions.monthly_fee')}
bind:value={subscription.monthly_fee} bind:value={subscription.monthly_fee}
placeholder={$t('placeholder.subscription_monthly_fee')} placeholder={$t('placeholder.subscription_monthly_fee')}
required={true} required={true}
@@ -105,7 +94,7 @@
<InputField <InputField
name="subscription[hourly_rate]" name="subscription[hourly_rate]"
type="number" type="number"
label={$t('subscription.hourly_rate')} label={$t('subscriptions.hourly_rate')}
bind:value={subscription.hourly_rate} bind:value={subscription.hourly_rate}
required={true} required={true}
readonly={subscription.id > 0} readonly={subscription.id > 0}
@@ -113,21 +102,21 @@
<InputField <InputField
name="subscription[included_hours_per_year]" name="subscription[included_hours_per_year]"
type="number" type="number"
label={$t('subscription.included_hours_per_year')} label={$t('subscriptions.included_hours_per_year')}
bind:value={subscription.included_hours_per_year} bind:value={subscription.included_hours_per_year}
readonly={subscription.id > 0} readonly={subscription.id > 0}
/> />
<InputField <InputField
name="included_hours_per_month" name="included_hours_per_month"
type="number" type="number"
label={$t('subscription.included_hours_per_month')} label={$t('subscriptions.included_hours_per_month')}
bind:value={subscription.included_hours_per_month} bind:value={subscription.included_hours_per_month}
readonly={subscription.id > 0} readonly={subscription.id > 0}
/> />
</div> </div>
<div class="button-container"> <div class="button-container">
{#if isUpdating} {#if isUpdating}
<SmallLoader width={30} message={'Aktualisiere...'} /> <SmallLoader width={30} message={$t('loading.updating')} />
{:else} {:else}
<button type="button" class="button-dark" on:click={() => dispatch('cancel')}> <button type="button" class="button-dark" on:click={() => dispatch('cancel')}>
{$t('cancel')}</button {$t('cancel')}</button

View File

@@ -3,97 +3,43 @@
import SmallLoader from '$lib/components/SmallLoader.svelte'; import SmallLoader from '$lib/components/SmallLoader.svelte';
import { createEventDispatcher } from 'svelte'; import { createEventDispatcher } from 'svelte';
import { applyAction, enhance } from '$app/forms'; import { applyAction, enhance } from '$app/forms';
import { receive, send } from '$lib/utils/helpers'; import { hasPrivilige, receive, send } from '$lib/utils/helpers';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { PERMISSIONS } from '$lib/utils/constants';
// import { defaultBankAccount, defaultLicence, defaultMembership } from '$lib/utils/defaults';
/** @type {import('../../routes/auth/about/[id]/$types').ActionData} */ /** @type {import('../../routes/auth/about/[id]/$types').ActionData} */
export let form; export let form;
/** @type {App.Locals['subscriptions']}*/ /** @type {App.Locals['subscriptions'] | null}*/
export let subscriptions; export let subscriptions;
/** @type {App.Locals['user']} */ /** @type {App.Locals['user']} */
const blankUser = {
id: 0,
email: '',
first_name: '',
last_name: '',
password: '',
phone: '',
address: '',
zip_code: '',
city: '',
company: '',
dateofbirth: '',
notes: '',
profile_picture: '',
payment_status: 0,
status: 1,
role_id: 0,
membership: {
id: 0,
start_date: '',
end_date: '',
status: 3,
parent_member_id: 0,
subscription_model: {
id: 0,
name: '',
details: '',
conditions: '',
monthly_fee: 0,
hourly_rate: 0,
included_hours_per_month: 0,
included_hours_per_year: 0
}
},
licence: {
id: 0,
status: 1,
number: '',
issued_date: '',
expiration_date: '',
country: '',
categories: []
},
bank_account: {
id: 0,
mandate_date_signed: '',
bank: '',
account_holder_name: '',
iban: '',
bic: '',
mandate_reference: ''
}
};
/** @type {App.Locals['user'] | null} */
export let user; export let user;
/** @type {Number} */ export let submit_form = true;
export let role_id;
// Ensure licence is initialized before passing to child
// $: if (user && !user.licence) {
// user.licence = defaultLicence();
// }
// $: if (user && !user.membership) {
// user.membership = defaultMembership();
// }
// $: if (user && !user.bank_account) {
// user.bank_account = defaultBankAccount();
// }
/** @type {App.Locals['user']} */ /** @type {App.Locals['user']} */
let localUser; export let editor;
$: { let readonlyUser = !hasPrivilige(editor, PERMISSIONS.Update);
if (user !== undefined && !localUser) {
localUser =
user === null
? { ...blankUser }
: {
...user,
licence: user.licence || blankUser.licence,
membership: user.membership || blankUser.membership,
bank_account: user.bank_account || blankUser.bank_account
};
}
}
// $: isNewUser = user === null; // $: isNewUser = user === null;
$: isLoading = user === undefined; $: isLoading = user === undefined;
$: if (user != null) {
/** @type {App.Locals['licence_categories']} */ console.log(user);
}
/** @type {App.Locals['licence_categories'] | null} */
export let licence_categories; export let licence_categories;
const userStatusOptions = [ const userStatusOptions = [
@@ -104,10 +50,12 @@
{ value: 5, label: $t('userStatus.5'), color: '--red' } // Red for "Deaktiviert" { value: 5, label: $t('userStatus.5'), color: '--red' } // Red for "Deaktiviert"
]; ];
const userRoleOptions = [ const userRoleOptions = [
{ value: -1, label: $t('userRole.-1'), color: '--red' }, // Red for "Opponent"
{ value: 0, label: $t('userRole.0'), color: '--subtext1' }, // Grey for "Nicht verifiziert" { value: 0, label: $t('userRole.0'), color: '--subtext1' }, // Grey for "Nicht verifiziert"
{ value: 1, label: $t('userRole.1'), color: '--light-green' }, // Light green for "Verifiziert" { value: 1, label: $t('userRole.1'), color: '--light-green' }, // Light green for "Verifiziert"
{ value: 4, label: $t('userRole.4'), color: '--green' }, // Green for "Aktiv" { value: 2, label: $t('userRole.2'), color: '--green' }, // Light green for "Verifiziert"
{ value: 8, label: $t('userRole.8'), color: '--pink' } // Pink for "Passiv" { value: 4, label: $t('userRole.4'), color: '--pink' }, // Green for "Aktiv"
{ value: 8, label: $t('userRole.8'), color: '--red' } // Pink for "Passiv"
]; ];
const membershipStatusOptions = [ const membershipStatusOptions = [
{ value: 3, label: $t('userStatus.3'), color: '--green' }, // Green for "Aktiv" { value: 3, label: $t('userStatus.3'), color: '--green' }, // Green for "Aktiv"
@@ -122,23 +70,26 @@
]; ];
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const TABS = ['profile', 'licence', 'membership', 'bankaccount']; /** @type { (keyof user)[] } */
let activeTab = TABS[0]; const TABS = ['membership', 'licence', 'bank_account'];
let activeTab = 'profile';
let isUpdating = false, let isUpdating = false,
password = '', password = '',
password2 = ''; confirm_password = '';
/** @type {Object.<string, App.Locals['licence_categories']>} */ /** @type {Object.<string, App.Locals['licence_categories']>} */
$: groupedCategories = groupCategories(licence_categories); $: groupedCategories = licence_categories ? groupCategories(licence_categories) : {};
$: subscriptionModelOptions = subscriptions.map((sub) => ({ $: subscriptionOptions = subscriptions
? subscriptions.map((sub) => ({
value: sub?.name ?? '', value: sub?.name ?? '',
label: sub?.name ?? '' label: sub?.name ?? ''
})); }))
$: selectedSubscriptionModel = : [];
subscriptions.find((sub) => sub?.name === localUser.membership?.subscription_model.name) || $: selectedSubscription = subscriptions
null; ? subscriptions.find((sub) => sub?.name === user.membership?.subscription.name) || null
: null;
/** /**
* creates groups of categories depending on the first letter * creates groups of categories depending on the first letter
* @param {App.Locals['licence_categories']} categories - the categories to sort and group * @param {App.Locals['licence_categories']} categories - the categories to sort and group
@@ -160,36 +111,47 @@
); );
} }
/** /** @type {import('@sveltejs/kit').SubmitFunction} */
* Sets the active tab const handleUpdate = ({ cancel }) => {
* @param {string} tab - The tab to set as active if (!submit_form) {
*/ cancel();
function setActiveTab(tab) { dispatch('close');
activeTab = tab; return;
} }
/** @type {import('../../routes/auth/about/[id]/$types').SubmitFunction} */
const handleUpdate = async () => {
isUpdating = true; isUpdating = true;
return async ({ result }) => { return async ({ result }) => {
isUpdating = false; isUpdating = false;
if (result.type === 'success' || result.type === 'redirect') { if (result.type === 'success' || result.type === 'redirect') {
dispatch('close'); dispatch('close');
} else { } else {
document.querySelector('.modal .container')?.scrollTo({ top: 0, behavior: 'smooth' }); document.querySelector('.modal .container')?.scrollTo({ top: 0, behavior: 'smooth' });
} }
await applyAction(result); console.log('submitting');
return submit_form ? await applyAction(result) : undefined;
}; };
}; };
</script> </script>
{#if isLoading} {#if isLoading}
<SmallLoader width={30} message={$t('loading.user_data')} /> <SmallLoader width={30} message={$t('loading.user_data')} />
{:else if localUser} {:else if user}
<form class="content" action="?/updateUser" method="POST" use:enhance={handleUpdate}> <form
<input name="user[id]" type="hidden" bind:value={localUser.id} /> class="content"
action="?/updateUser"
method="POST"
use:enhance={handleUpdate}
on:submit={(/** @type{SubmitEvent}*/ e) => {
if (!submit_form) {
e.preventDefault();
dispatch('close');
}
}}
>
<input name="user[id]" type="hidden" bind:value={user.id} />
<h1 class="step-title" style="text-align: center;"> <h1 class="step-title" style="text-align: center;">
{localUser.id ? $t('user.edit') : $t('user.create')} {user.id ? $t('user.edit') : $t('user.create')}
</h1> </h1>
{#if form?.success} {#if form?.success}
<h4 <h4
@@ -214,167 +176,183 @@
{/if} {/if}
<div class="button-container"> <div class="button-container">
<button
type="button"
class="button-dark"
class:active={activeTab === 'profile'}
on:click={() => (activeTab = 'profile')}
>
{$t('profile')}
</button>
{#each TABS as tab} {#each TABS as tab}
{#if user[tab] != null}
<button <button
type="button" type="button"
class="button-dark" class="button-dark"
class:active={activeTab === tab} class:active={activeTab === tab}
on:click={() => setActiveTab(tab)} on:click={() => (activeTab = tab)}
> >
{$t(tab)} {$t('user.' + tab)}
</button> </button>
{/if}
{/each} {/each}
</div> </div>
<div class="tab-content" style="display: {activeTab === 'profile' ? 'block' : 'none'}"> <div class="tab-content" style="display: {activeTab === 'profile' ? 'block' : 'none'}">
{#if hasPrivilige(user, PERMISSIONS.Member)}
<InputField <InputField
name="user[status]" name="user[status]"
type="select" type="select"
label={$t('status')} label={$t('status')}
bind:value={localUser.status} bind:value={user.status}
options={userStatusOptions} options={userStatusOptions}
readonly={role_id === 0} readonly={readonlyUser}
/> />
{#if localUser.role_id === 8} {/if}
{#if hasPrivilige(editor, PERMISSIONS.Super)}
<InputField <InputField
name="user[role_id]" name="user[role_id]"
type="select" type="select"
label={$t('user.role')} label={$t('user.role')}
bind:value={localUser.role_id} bind:value={user.role_id}
options={userRoleOptions} options={userRoleOptions}
/> />
{/if} {/if}
{#if hasPrivilige(user, PERMISSIONS.Member)}
<InputField <InputField
name="user[password]" name="user[password]"
type="password" type="password"
label={$t('password')} label={$t('password')}
placeholder={$t('placeholder.password')} placeholder={$t('placeholder.password')}
bind:value={password} bind:value={password}
otherPasswordValue={password2} otherPasswordValue={confirm_password}
/> />
<InputField <InputField
name="password2" name="confirm_password"
type="password" type="password"
label={$t('password_repeat')} label={$t('confirm_password')}
placeholder={$t('placeholder.password')} placeholder={$t('placeholder.password')}
bind:value={password2} bind:value={confirm_password}
otherPasswordValue={password} otherPasswordValue={password}
/> />
{/if}
<InputField <InputField
name="user[first_name]" name="user[first_name]"
label={$t('first_name')} label={$t('user.first_name')}
bind:value={localUser.first_name} bind:value={user.first_name}
placeholder={$t('placeholder.first_name')} placeholder={$t('placeholder.first_name')}
required={true} required={true}
readonly={role_id === 0} readonly={readonlyUser}
/> />
<InputField <InputField
name="user[last_name]" name="user[last_name]"
label={$t('last_name')} label={$t('user.last_name')}
bind:value={localUser.last_name} bind:value={user.last_name}
placeholder={$t('placeholder.last_name')} placeholder={$t('placeholder.last_name')}
required={true} required={true}
readonly={role_id === 0} readonly={readonlyUser}
/> />
<InputField <InputField
name="user[company]" name="user[company]"
label={$t('company')} label={$t('company')}
bind:value={localUser.company} bind:value={user.company}
placeholder={$t('placeholder.company')} placeholder={$t('placeholder.company')}
/> />
<InputField <InputField
name="user[email]" name="user[email]"
type="email" type="email"
label={$t('user.email')} label={$t('user.email')}
bind:value={localUser.email} bind:value={user.email}
placeholder={$t('placeholder.email')} placeholder={$t('placeholder.email')}
required={true} required={true}
/> />
<InputField <InputField
name="user[phone]" name="user[phone]"
type="tel" type="tel"
label={$t('phone')} label={$t('user.phone')}
bind:value={localUser.phone} bind:value={user.phone}
placeholder={$t('placeholder.phone')} placeholder={$t('placeholder.phone')}
/> />
<InputField <InputField
name="user[dateofbirth]" name="user[dateofbirth]"
type="date" type="date"
label={$t('dateofbirth')} label={$t('user.dateofbirth')}
bind:value={localUser.dateofbirth} bind:value={user.dateofbirth}
placeholder={$t('placeholder.dateofbirth')} placeholder={$t('placeholder.dateofbirth')}
readonly={role_id === 0} readonly={readonlyUser}
/> />
<InputField <InputField
name="user[address]" name="user[address]"
label={$t('address')} label={$t('address')}
bind:value={localUser.address} bind:value={user.address}
placeholder={$t('placeholder.address')} placeholder={$t('placeholder.address')}
/> />
<InputField <InputField
name="user[zip_code]" name="user[zip_code]"
label={$t('zip_code')} label={$t('zip_code')}
bind:value={localUser.zip_code} bind:value={user.zip_code}
placeholder={$t('placeholder.zip_code')} placeholder={$t('placeholder.zip_code')}
/> />
<InputField <InputField
name="user[city]" name="user[city]"
label={$t('city')} label={$t('city')}
bind:value={localUser.city} bind:value={user.city}
placeholder={$t('placeholder.city')} placeholder={$t('placeholder.city')}
/> />
{#if localUser.role_id > 0} {#if !readonlyUser}
<InputField <InputField
name="user[notes]" name="user[notes]"
type="textarea" type="textarea"
label={$t('notes')} label={$t('notes')}
bind:value={localUser.notes} bind:value={user.notes}
placeholder={$t('placeholder.notes', { placeholder={$t('placeholder.notes', {
values: { name: localUser.first_name || '' } values: { name: user.first_name || '' }
})} })}
rows={10} rows={10}
/> />
{/if} {/if}
</div> </div>
{#if hasPrivilige(user, PERMISSIONS.Member) && user.licence}
<div class="tab-content" style="display: {activeTab === 'licence' ? 'block' : 'none'}"> <div class="tab-content" style="display: {activeTab === 'licence' ? 'block' : 'none'}">
<InputField <InputField
name="user[licence][status]" name="user[licence][status]"
type="select" type="select"
label={$t('status')} label={$t('status')}
bind:value={localUser.licence.status} bind:value={user.licence.status}
options={licenceStatusOptions} options={licenceStatusOptions}
readonly={role_id === 0} readonly={readonlyUser}
/> />
<InputField <InputField
name="user[licence][number]" name="user[licence][number]"
type="text" type="text"
label={$t('licence_number')} label={$t('licence_number')}
bind:value={localUser.licence.number} bind:value={user.licence.number}
placeholder={$t('placeholder.licence_number')} placeholder={$t('placeholder.licence_number')}
toUpperCase={true} toUpperCase={true}
readonly={role_id === 0} readonly={readonlyUser}
/> />
<InputField <InputField
name="user[licence][issued_date]" name="user[licence][issued_date]"
type="date" type="date"
label={$t('issued_date')} label={$t('issued_date')}
bind:value={localUser.licence.issued_date} bind:value={user.licence.issued_date}
placeholder={$t('placeholder.issued_date')} placeholder={$t('placeholder.issued_date')}
readonly={role_id === 0} readonly={readonlyUser}
/> />
<InputField <InputField
name="user[licence][expiration_date]" name="user[licence][expiration_date]"
type="date" type="date"
label={$t('expiration_date')} label={$t('expiration_date')}
bind:value={localUser.licence.expiration_date} bind:value={user.licence.expiration_date}
placeholder={$t('placeholder.expiration_date')} placeholder={$t('placeholder.expiration_date')}
readonly={role_id === 0} readonly={readonlyUser}
/> />
<InputField <InputField
name="user[licence][country]" name="user[licence][country]"
label={$t('country')} label={$t('country')}
bind:value={localUser.licence.country} bind:value={user.licence.country}
placeholder={$t('placeholder.issuing_country')} placeholder={$t('placeholder.issuing_country')}
readonly={role_id === 0} readonly={readonlyUser}
/> />
<div class="licence-categories"> <div class="licence-categories">
<h3>{$t('licence_categories')}</h3> <h3>{$t('licence_categories')}</h3>
@@ -391,10 +369,8 @@
name="user[licence][categories][]" name="user[licence][categories][]"
value={JSON.stringify(category)} value={JSON.stringify(category)}
label={category.category} label={category.category}
checked={localUser.licence.categories != null && checked={user.licence.categories != null &&
localUser.licence.categories.some( user.licence.categories.some((cat) => cat.category === category.category)}
(cat) => cat.category === category.category
)}
/> />
</div> </div>
<span class="checkbox-description"> <span class="checkbox-description">
@@ -406,55 +382,62 @@
</div> </div>
</div> </div>
</div> </div>
<div class="tab-content" style="display: {activeTab === 'membership' ? 'block' : 'none'}"> {/if}
{#if user.membership}
<div
class="tab-content"
style="display: {activeTab === 'membership' && subscriptions ? 'block' : 'none'}"
>
<InputField <InputField
name="user[membership][status]" name="user[membership][status]"
type="select" type="select"
label={$t('status')} label={$t('status')}
bind:value={localUser.membership.status} bind:value={user.membership.status}
options={membershipStatusOptions} options={membershipStatusOptions}
readonly={role_id === 0} readonly={readonlyUser}
/> />
<InputField <InputField
name="user[membership][subscription_model][name]" name="user[membership][subscription][name]"
type="select" type="select"
label={$t('subscription.subscription')} label={$t('subscriptions.subscription')}
bind:value={localUser.membership.subscription_model.name} bind:value={user.membership.subscription.name}
options={subscriptionModelOptions} options={subscriptionOptions}
readonly={role_id === 0} readonly={readonlyUser || !hasPrivilige(user, PERMISSIONS.Member)}
/> />
<div class="subscription-info"> <div class="subscription-info">
{#if hasPrivilige(user, PERMISSIONS.Member)}
<div class="subscription-column"> <div class="subscription-column">
<p> <p>
<strong>{$t('subscription.monthly_fee')}:</strong> <strong>{$t('subscriptions.monthly_fee')}:</strong>
{selectedSubscriptionModel?.monthly_fee || '-'} {selectedSubscription?.monthly_fee || '-'}
</p> </p>
<p> <p>
<strong>{$t('subscription.hourly_rate')}:</strong> <strong>{$t('subscriptions.hourly_rate')}:</strong>
{selectedSubscriptionModel?.hourly_rate || '-'} {selectedSubscription?.hourly_rate || '-'}
</p> </p>
{#if selectedSubscriptionModel?.included_hours_per_year} {#if selectedSubscription?.included_hours_per_year}
<p> <p>
<strong>{$t('subscription.included_hours_per_year')}:</strong> <strong>{$t('subscriptions.included_hours_per_year')}:</strong>
{selectedSubscriptionModel?.included_hours_per_year} {selectedSubscription?.included_hours_per_year}
</p> </p>
{/if} {/if}
{#if selectedSubscriptionModel?.included_hours_per_month} {#if selectedSubscription?.included_hours_per_month}
<p> <p>
<strong>{$t('subscription.included_hours_per_month')}:</strong> <strong>{$t('subscriptions.included_hours_per_month')}:</strong>
{selectedSubscriptionModel?.included_hours_per_month} {selectedSubscription?.included_hours_per_month}
</p> </p>
{/if} {/if}
</div> </div>
{/if}
<div class="subscription-column"> <div class="subscription-column">
<p> <p>
<strong>{$t('details')}:</strong> <strong>{$t('details')}:</strong>
{selectedSubscriptionModel?.details || '-'} {selectedSubscription?.details || '-'}
</p> </p>
{#if selectedSubscriptionModel?.conditions} {#if selectedSubscription?.conditions}
<p> <p>
<strong>{$t('subscription.conditions')}:</strong> <strong>{$t('subscriptions.conditions')}:</strong>
{selectedSubscriptionModel?.conditions} {selectedSubscription?.conditions}
</p> </p>
{/if} {/if}
</div> </div>
@@ -463,72 +446,77 @@
name="user[membership][start_date]" name="user[membership][start_date]"
type="date" type="date"
label={$t('start')} label={$t('start')}
bind:value={localUser.membership.start_date} bind:value={user.membership.start_date}
placeholder={$t('placeholder.start_date')} placeholder={$t('placeholder.start_date')}
readonly={role_id === 0} readonly={readonlyUser}
/> />
<InputField <InputField
name="user[membership][end_date]" name="user[membership][end_date]"
type="date" type="date"
label={$t('end')} label={$t('end')}
bind:value={localUser.membership.end_date} bind:value={user.membership.end_date}
placeholder={$t('placeholder.end_date')} placeholder={$t('placeholder.end_date')}
readonly={role_id === 0} readonly={readonlyUser}
/> />
{#if hasPrivilige(user, PERMISSIONS.Member)}
<InputField <InputField
name="user[membership][parent_member_id]" name="user[membership][parent_member_id]"
type="number" type="number"
label={$t('parent_member_id')} label={$t('parent_member_id')}
bind:value={localUser.membership.parent_member_id} bind:value={user.membership.parent_member_id}
placeholder={$t('placeholder.parent_member_id')} placeholder={$t('placeholder.parent_member_id')}
readonly={role_id === 0} readonly={readonlyUser}
/> />
{/if}
</div> </div>
<div class="tab-content" style="display: {activeTab === 'bankaccount' ? 'block' : 'none'}"> {/if}
{#if user.bank_account}
<div class="tab-content" style="display: {activeTab === 'bank_account' ? 'block' : 'none'}">
<InputField <InputField
name="user[bank_account][account_holder_name]" name="user[bank_account][account_holder_name]"
label={$t('bank_account_holder')} label={$t('bank_account_holder')}
bind:value={localUser.bank_account.account_holder_name} bind:value={user.bank_account.account_holder_name}
placeholder={$t('placeholder.bank_account_holder')} placeholder={$t('placeholder.bank_account_holder')}
/> />
<InputField <InputField
name="user[bank_account][bank_name]" name="user[bank_account][bank_name]"
label={$t('bank_name')} label={$t('bank_name')}
bind:value={localUser.bank_account.bank} bind:value={user.bank_account.bank}
placeholder={$t('placeholder.bank_name')} placeholder={$t('placeholder.bank_name')}
/> />
<InputField <InputField
name="user[bank_account][iban]" name="user[bank_account][iban]"
label={$t('iban')} label={$t('iban')}
bind:value={localUser.bank_account.iban} bind:value={user.bank_account.iban}
placeholder={$t('placeholder.iban')} placeholder={$t('placeholder.iban')}
toUpperCase={true} toUpperCase={true}
/> />
<InputField <InputField
name="user[bank_account][bic]" name="user[bank_account][bic]"
label={$t('bic')} label={$t('bic')}
bind:value={localUser.bank_account.bic} bind:value={user.bank_account.bic}
placeholder={$t('placeholder.bic')} placeholder={$t('placeholder.bic')}
toUpperCase={true} toUpperCase={true}
/> />
<InputField <InputField
name="user[bank_account][mandate_reference]" name="user[bank_account][mandate_reference]"
label={$t('mandate_reference')} label={$t('mandate_reference')}
bind:value={localUser.bank_account.mandate_reference} bind:value={user.bank_account.mandate_reference}
placeholder={$t('placeholder.mandate_reference')} placeholder={$t('placeholder.mandate_reference')}
readonly={role_id === 0} readonly={readonlyUser}
/> />
<InputField <InputField
name="user[bank_account][mandate_date_signed]" name="user[bank_account][mandate_date_signed]"
label={$t('mandate_date_signed')} label={$t('mandate_date_signed')}
type="date" type="date"
bind:value={localUser.bank_account.mandate_date_signed} bind:value={user.bank_account.mandate_date_signed}
readonly={true} readonly={true}
/> />
</div> </div>
{/if}
<div class="button-container"> <div class="button-container">
{#if isUpdating} {#if isUpdating}
<SmallLoader width={30} message={'Aktualisiere...'} /> <SmallLoader width={30} message={$t('loading.updating')} />
{:else} {:else}
<button type="button" class="button-dark" on:click={() => dispatch('cancel')}> <button type="button" class="button-dark" on:click={() => dispatch('cancel')}>
{$t('cancel')}</button {$t('cancel')}</button

View File

@@ -1,18 +1,26 @@
export default { export default {
userStatus: { userStatus: {
1: 'Nicht verifiziert', 1: 'Nicht verifiziert',
2: 'Verifiziert', 2: 'Deaktiviert',
3: 'Aktiv', 3: 'Verifiziert',
4: 'Passiv', 4: 'Systemzugang',
5: 'Deaktiviert' 5: 'Passiv'
}, },
userRole: { userRole: {
0: 'Mitglied', '-1': 'Unfallgegner',
1: 'Betrachter', 0: 'Sponsor',
1: 'Mitglied',
2: 'Betrachter',
4: 'Bearbeiter', 4: 'Bearbeiter',
8: 'Administrator' 8: 'Administrator'
}, },
placeholder: { placeholder: {
car_name: 'Hat das Fahrzeug einen Namen?',
car_brand: 'Fahrzeughersteller eingeben...',
car_model: 'Fahrzeugmodell eingeben...',
car_color: 'Fahrzeugfarbe eingeben...',
car_licence_plate: 'Fahrzeugkennzeichen eingeben...',
insurance_reference: 'Versicherungsnummer eingeben...',
password: 'Passwort eingeben...', password: 'Passwort eingeben...',
email: 'Emailadresse eingeben...', email: 'Emailadresse eingeben...',
company: 'Firmennamen eingeben...', company: 'Firmennamen eingeben...',
@@ -51,6 +59,7 @@ export default {
licence: 'Nummer zu kurz(11 Zeichen)' licence: 'Nummer zu kurz(11 Zeichen)'
}, },
server: { server: {
general: 'Allgemein',
error: { error: {
invalid_json: 'JSON Daten sind ungültig', invalid_json: 'JSON Daten sind ungültig',
no_auth_token: 'Nicht authorisiert, fehlender oder ungültiger Auth-Token', no_auth_token: 'Nicht authorisiert, fehlender oder ungültiger Auth-Token',
@@ -58,15 +67,28 @@ export default {
unauthorized: 'Sie sind nicht befugt diese Handlung durchzuführen', unauthorized: 'Sie sind nicht befugt diese Handlung durchzuführen',
internal_server_error: internal_server_error:
'Verdammt, Fehler auf unserer Seite, probieren Sie es nochmal, danach rufen Sie jemanden vom Verein an.', 'Verdammt, Fehler auf unserer Seite, probieren Sie es nochmal, danach rufen Sie jemanden vom Verein an.',
not_possible: 'Vorgang nicht möglich.' not_possible: 'Vorgang nicht möglich.',
not_found: 'Konnte nicht gefunden werden.',
in_use: 'Ist in Benutzung',
undelivered_verification_mail:
'Registrierung erfolgreicht, leider konnte die Verifizierungs-E-Mail nicht versendet werden. Bitte wenden Sie sich an den Verein um Ihre Emailadresse zu bestätigen und Ihren Account zu aktivieren.'
}, },
validation: { validation: {
invalid: 'ungültig',
invalid_user_id: 'Nutzer ID ungültig', invalid_user_id: 'Nutzer ID ungültig',
invalid_subscription_model: 'Model nicht gefunden', invalid_subscription: 'Model nicht gefunden',
user_not_found: '{field} konnte nicht gefunden werden', user_not_found: '{field} konnte nicht gefunden werden',
invalid_user_data: 'Nutzerdaten ungültig', invalid_user_data: 'Nutzerdaten ungültig',
user_not_found_or_wrong_password: 'Existiert nicht oder falsches Passwort', user_not_found_or_wrong_password: 'Existiert nicht oder falsches Passwort',
email_already_registered: 'Ein Mitglied wurde schon mit dieser Emailadresse erstellt.', email_already_registered: 'Ein Mitglied wurde schon mit dieser Emailadresse erstellt.',
password_already_changed: 'Das Passwort wurde schon geändert.',
user_already_verified: 'Ihre Email Adresse wurde schon bestätigt.',
insecure: 'Unsicheres Passwort, versuchen Sie {message}',
longer: 'oder verwenden Sie ein längeres Passwort',
special: 'mehr Sonderzeichen einzufügen',
lowercase: 'Kleinbuchstaben zu verwenden',
uppercase: 'Großbuchstaben zu verwenden',
numbers: 'Zahlen zu verwenden',
alphanumunicode: 'beinhaltet nicht erlaubte Zeichen', alphanumunicode: 'beinhaltet nicht erlaubte Zeichen',
safe_content: 'I see what you did there! Do not cross this line!', safe_content: 'I see what you did there! Do not cross this line!',
iban: 'Ungültig. Format: DE07123412341234123412', iban: 'Ungültig. Format: DE07123412341234123412',
@@ -79,7 +101,10 @@ export default {
required: 'Feld wird benötigt', required: 'Feld wird benötigt',
image: 'Dies ist kein Bild', image: 'Dies ist kein Bild',
alphanum: 'beinhaltet ungültige Zeichen', alphanum: 'beinhaltet ungültige Zeichen',
alphaunicode: 'darf nur aus Buchstaben bestehen' user_disabled: 'Benutzer ist deaktiviert',
duplicate: 'Schon vorhanden..',
alphaunicode: 'darf nur aus Buchstaben bestehen',
too_soon: 'zu früh'
} }
}, },
licenceCategory: { licenceCategory: {
@@ -106,14 +131,22 @@ export default {
edit: 'Nutzer bearbeiten', edit: 'Nutzer bearbeiten',
create: 'Nutzer erstellen', create: 'Nutzer erstellen',
user: 'Nutzer', user: 'Nutzer',
member: 'Mitglied',
management: 'Mitgliederverwaltung', management: 'Mitgliederverwaltung',
id: 'Mitgliedsnr', id: 'Mitgliedsnr',
name: 'Name', first_name: 'Vorname',
last_name: 'Nachname',
phone: 'Telefonnummer',
dateofbirth: 'Geburtstag',
email: 'Email', email: 'Email',
membership: 'Mitgliedschaft',
bank_account: 'Kontodaten',
status: 'Status', status: 'Status',
role: 'Nutzerrolle' role: 'Nutzerrolle',
supporter: 'Sponsor',
opponent: 'Unfallgegner'
}, },
subscription: { subscriptions: {
name: 'Modellname', name: 'Modellname',
edit: 'Modell bearbeiten', edit: 'Modell bearbeiten',
create: 'Modell erstellen', create: 'Modell erstellen',
@@ -125,26 +158,63 @@ export default {
included_hours_per_year: 'Inkludierte Stunden pro Jahr', included_hours_per_year: 'Inkludierte Stunden pro Jahr',
included_hours_per_month: 'Inkludierte Stunden pro Monat' included_hours_per_month: 'Inkludierte Stunden pro Monat'
}, },
car: {
car: 'Fahrzeug',
model: 'Modell',
brand: 'Marke',
licence_plate: 'Kennzeichen',
edit: 'Fahrzeug bearbeiten',
create: 'Fahrzeug hinzufügen',
damages: 'Schäden',
start_date: 'Anschaffungsdatum',
end_date: 'Leasingende',
leasing_rate: 'Leasingrate'
},
insurances: {
edit: 'Daten bearbeiten',
create: 'Versicherung erstellen'
},
loading: { loading: {
user_data: 'Lade Nutzerdaten', user_data: 'Lade Nutzerdaten',
subscription_data: 'Lade Modelldaten' subscription_data: 'Lade Modelldaten',
insurance_data: 'Lade Versicherungsdaten',
car_data: 'Lade Fahrzeugdaten',
please_wait: 'Bitte warten...',
updating: 'Aktualisiere...'
}, },
dialog: { dialog: {
user_deletion: 'Soll der Nutzer {firstname} {lastname} wirklich gelöscht werden?', user_deletion: 'Soll der Nutzer {firstname} {lastname} wirklich gelöscht werden?',
subscription_deletion: 'Soll das Tarifmodell {name} wirklich gelöscht werden?' subscription_deletion: 'Soll das Tarifmodell {name} wirklich gelöscht werden?',
car_deletion: 'Soll das Fahrzeug {name} wirklich gelöscht werden?',
insurance_deletion: 'Soll die Versicherung {name} wirklich gelöscht werden?',
damage_deletion: 'Soll der Schaden {name} wirklich gelöscht werden?',
backend_access: 'Soll {firstname} {lastname} Backend Zugriff gewährt werden?'
}, },
cancel: 'Abbrechen', cancel: 'Abbrechen',
confirm: 'Bestätigen', confirm: 'Bestätigen',
actions: 'Aktionen', actions: 'Aktionen',
create: 'Hinzufügen',
edit: 'Bearbeiten', edit: 'Bearbeiten',
delete: 'Löschen', delete: 'Löschen',
not_set: 'Nicht gesetzt',
noone: 'Niemand',
search: 'Suche:', search: 'Suche:',
name: 'Name',
date: 'Datum',
price: 'Preis',
color: 'Farbe',
grant_backend_access: 'Backend Zugriff gewähren',
no_insurance: 'Keine Versicherung',
supporter: 'Sponsoren',
mandate_date_signed: 'Mandatserteilungsdatum', mandate_date_signed: 'Mandatserteilungsdatum',
licence_categories: 'Führerscheinklassen', licence_categories: 'Führerscheinklassen',
subscription_model: 'Mitgliedschatfsmodell', subscription: 'Mitgliedschatfsmodell',
licence: 'Führerschein', licence: 'Führerschein',
licence_number: 'Führerscheinnummer', licence_number: 'Führerscheinnummer',
insurance: 'Versicherung',
insurance_reference: 'Versicherungsnummer',
issued_date: 'Ausgabedatum', issued_date: 'Ausgabedatum',
month: 'Monat',
expiration_date: 'Ablaufdatum', expiration_date: 'Ablaufdatum',
country: 'Land', country: 'Land',
details: 'Details', details: 'Details',
@@ -155,16 +225,15 @@ export default {
zip_code: 'PLZ', zip_code: 'PLZ',
forgot_password: 'Passwort vergessen?', forgot_password: 'Passwort vergessen?',
password: 'Passwort', password: 'Passwort',
password_repeat: 'Passwort wiederholen', confirm_password: 'Passwort wiederholen',
password_changed: 'Passwort wurde erfolgreich geändert.',
change_password: 'Passwort ändern',
password_change_requested:
'Passwortänderungsanfrage wurde gesendet.. Bitte überprüfen Sie Ihr Postfach.',
company: 'Firma', company: 'Firma',
login: 'Anmeldung', login: 'Anmeldung',
profile: 'Profil', profile: 'Profil',
membership: 'Mitgliedschaft', cars: 'Fahrzeuge',
bankaccount: 'Kontodaten',
first_name: 'Vorname',
last_name: 'Nachname',
phone: 'Telefonnummer',
dateofbirth: 'Geburtstag',
status: 'Status', status: 'Status',
start: 'Beginn', start: 'Beginn',
end: 'Ende', end: 'Ende',
@@ -176,7 +245,8 @@ export default {
mandate_reference: 'SEPA Mandat', mandate_reference: 'SEPA Mandat',
payments: 'Zahlungen', payments: 'Zahlungen',
add_new: 'Neu', add_new: 'Neu',
email_sent: 'Email wurde gesendet..',
verification: 'Verifikation',
// For payments section // For payments section
payment: { payment: {
id: 'Zahlungs-Nr', id: 'Zahlungs-Nr',
@@ -184,7 +254,6 @@ export default {
date: 'Datum', date: 'Datum',
status: 'Status' status: 'Status'
}, },
// For subscription statuses // For subscription statuses
subscriptionStatus: { subscriptionStatus: {
pending: 'Ausstehend', pending: 'Ausstehend',

View File

@@ -1,15 +1,210 @@
export default { export default {
userStatus: { userStatus: {
1: "Unverified", 1: 'Not Verified',
2: "Verified", 2: 'Deactivated',
3: "Active", 3: 'Verified',
4: "Passive", 4: 'System Access',
5: "Disabled", 5: 'Passive'
}, },
userRole: { userRole: {
0: "Member", 0: 'Sponsor',
1: "Viewer", 1: 'Member',
4: "Editor", 2: 'Viewer',
8: "Admin", 4: 'Editor',
8: 'Administrator'
}, },
placeholder: {
password: 'Enter password...',
email: 'Enter email address...',
company: 'Enter company name...',
first_name: 'Enter first name...',
last_name: 'Enter last name...',
phone: 'Enter phone number...',
address: 'Enter street and house number...',
zip_code: 'Enter postal code...',
city: 'Enter city...',
bank_name: 'Enter bank name...',
parent_member_id: 'Enter parent member ID...',
bank_account_holder: 'Enter name...',
iban: 'Enter IBAN...',
bic: 'Enter BIC (for non-German accounts)...',
mandate_reference: 'Enter SEPA mandate reference...',
notes: 'Your notes about {name}...',
licence_number: 'On the drivers licence under field 5',
issued_date: 'Issue date under field 4a',
expiration_date: 'Expiration date under field 4b',
issuing_country: 'Issuing country',
subscription_name: 'Subscription model name',
subscription_details: 'Describe the subscription model...',
subscription_conditions: 'Describe the usage conditions...',
search: 'Search...'
},
validation: {
required: 'Input required',
password: 'Password too short, at least 8 characters',
password_match: 'Passwords do not match!',
phone: 'Invalid format (+491738762387 or 0173850698)',
zip_code: 'Invalid postal code (Only German locations are allowed)',
bic: 'Invalid BIC',
iban: 'Invalid IBAN',
date: 'Please enter a date',
email: 'Invalid email address',
licence: 'Number too short (11 characters)'
},
server: {
general: 'General',
error: {
invalid_json: 'Invalid JSON data',
no_auth_token: 'Unauthorized, missing or invalid auth token',
jwt_parsing_error: 'Unauthorized, auth token could not be read',
unauthorized: 'You are not authorized to perform this action',
internal_server_error:
'Damn, error on our side, try again, then contact someone from the organization.',
not_possible: 'Operation not possible.',
not_found: 'Could not be found.',
in_use: 'Is in use',
undelivered_verification_mail:
'Registration successful, but the verification email could not be sent. Please contact the organization to verify your email address and activate your account.'
},
validation: {
invalid: 'Invalid',
invalid_user_id: 'Invalid user ID',
invalid_subscription: 'Model not found',
user_not_found: '{field} could not be found',
invalid_user_data: 'Invalid user data',
user_not_found_or_wrong_password: 'Does not exist or wrong password',
email_already_registered: 'A member has already been created with this email address.',
password_already_changed: 'The password has already been changed.',
alphanumunicode: 'Contains disallowed characters',
safe_content: 'I see what you did there! Do not cross this line!',
iban: 'Invalid. Format: DE07123412341234123412',
bic: 'Invalid. Format: BELADEBEXXX',
email: 'Invalid format',
number: 'Is not a number',
euDriversLicence: 'Is not a European drivers licence',
lte: 'Is too large/new',
gt: 'Is too small/old',
required: 'Field is required',
image: 'This is not an image',
alphanum: 'Contains invalid characters',
user_disabled: 'User is disabled',
duplicate: 'Already exists...',
alphaunicode: 'Must consist only of letters',
too_soon: 'Too soon'
}
},
licenceCategory: {
AM: 'Mopeds and light four-wheeled vehicles (50cc, max 45 km/h)',
A1: 'Light motorcycles (125cc)',
A2: 'Medium-power motorcycles (max 35 kW)',
A: 'Motorcycles',
B: 'Motor vehicles ≤ 3500 kg, ≤ 8 seats',
C1: 'Medium-heavy vehicles - 7500 kg',
C: 'Heavy commercial vehicles > 3500 kg',
D1: 'Minibuses with 9-16 seats',
D: 'Buses > 8 seats',
BE: 'Vehicle class B with trailer',
C1E: 'Vehicle class C1 with trailer',
CE: 'Vehicle class C with trailer',
D1E: 'Vehicle class D1 with trailer',
DE: 'Vehicle class D with trailer',
L: 'Agricultural, forestry vehicles, forklifts max 40 km/h',
T: 'Agricultural, forestry vehicles, forklifts max 60 km/h'
},
users: 'Members',
user: {
login: 'User Login',
edit: 'Edit User',
create: 'Create User',
user: 'User',
management: 'Member Management',
id: 'Member ID',
first_name: 'First Name',
last_name: 'Last Name',
phone: 'Phone Number',
dateofbirth: 'Date of Birth',
email: 'Email',
status: 'Status',
role: 'User Role',
supporter: 'Sponsor'
},
subscriptions: {
name: 'Model Name',
edit: 'Edit Model',
create: 'Create Model',
subscription: 'Subscription Model',
subscriptions: 'Subscription Models',
conditions: 'Conditions',
monthly_fee: 'Monthly Fee',
hourly_rate: 'Hourly Rate',
included_hours_per_year: 'Included Hours Per Year',
included_hours_per_month: 'Included Hours Per Month'
},
loading: {
user_data: 'Loading user data',
subscription_data: 'Loading model data',
please_wait: 'Please wait...',
updating: 'Updating...'
},
dialog: {
user_deletion: 'Should the user {firstname} {lastname} really be deleted?',
subscription_deletion: 'Should the subscription model {name} really be deleted?'
},
cancel: 'Cancel',
confirm: 'Confirm',
actions: 'Actions',
edit: 'Edit',
delete: 'Delete',
search: 'Search:',
name: 'Name',
supporter: 'Sponsors',
mandate_date_signed: 'Mandate Signing Date',
licence_categories: 'Drivers licence Categories',
subscription: 'Membership Model',
licence: 'Drivers licence',
licence_number: 'Drivers licence Number',
issued_date: 'Issue Date',
expiration_date: 'Expiration Date',
country: 'Country',
details: 'Details',
unknown: 'Unknown',
notes: 'Notes',
address: 'Street & House Number',
city: 'City',
zip_code: 'ZIP Code',
forgot_password: 'Forgot Password?',
password: 'Password',
confirm_password: 'Repeat Password',
password_changed: 'Password successfully changed.',
change_password: 'Change Password',
password_change_requested: 'Password change request sent... Please check your inbox.',
company: 'Company',
login: 'Login',
profile: 'Profile',
membership: 'Membership',
bank_account: 'Bank Account',
status: 'Status',
start: 'Start',
end: 'End',
parent_member_id: 'Parent Member ID',
bank_account_holder: 'Account Holder',
bank_name: 'Bank Name',
iban: 'IBAN',
bic: 'BIC',
mandate_reference: 'SEPA Mandate',
payments: 'Payments',
add_new: 'New',
email_sent: 'Email has been sent...',
payment: {
id: 'Payment ID',
amount: 'Amount',
date: 'Date',
status: 'Status'
},
subscriptionStatus: {
pending: 'Pending',
completed: 'Completed',
failed: 'Failed',
cancelled: 'Cancelled'
}
}; };

View File

@@ -1,3 +1,12 @@
export const BASE_API_URI = import.meta.env.DEV export const BASE_API_URI = import.meta.env.DEV
? import.meta.env.VITE_BASE_API_URI_DEV ? import.meta.env.VITE_BASE_API_URI_DEV
: import.meta.env.VITE_BASE_API_URI_PROD; : import.meta.env.VITE_BASE_API_URI_PROD;
export const PERMISSIONS = {
Member: 1,
View: 2,
Update: 4,
Create: 4,
Delete: 4,
Super: 8
};

View File

@@ -0,0 +1,171 @@
// src/lib/utils/defaults.js
/**
* @returns {App.Types['subscription']}
*/
export function defaultSubscription() {
return {
id: 0,
name: '',
details: '',
conditions: '',
monthly_fee: 0,
hourly_rate: 0,
included_hours_per_year: 0,
included_hours_per_month: 0
};
}
/**
* @returns {App.Types['membership']}
*/
export function defaultMembership() {
return {
id: 0,
status: 3,
start_date: '',
end_date: '',
parent_member_id: 0,
subscription: defaultSubscription()
};
}
/**
* @returns {App.Types['bankAccount']}
*/
export function defaultBankAccount() {
return {
id: 0,
mandate_date_signed: '',
bank: '',
account_holder_name: '',
iban: '',
bic: '',
mandate_reference: ''
};
}
/**
* @returns {App.Types['licence']}
*/
export function defaultLicence() {
return {
id: 0,
status: 0,
number: '',
issued_date: '',
expiration_date: '',
country: '',
categories: []
};
}
/**
* @returns {App.Locals['user']}
*/
export function defaultUser() {
return {
id: 0,
email: '',
first_name: '',
last_name: '',
password: '',
phone: '',
address: '',
zip_code: '',
city: '',
company: '',
dateofbirth: '',
notes: '',
status: 1,
role_id: 1,
membership: defaultMembership(),
licence: defaultLicence(),
bank_account: defaultBankAccount()
};
}
/**
* @returns {App.Locals['user']}
*/
export function defaultSupporter() {
let supporter = defaultUser();
supporter.status = 5;
supporter.role_id = 0;
supporter.licence = null;
supporter.membership = null;
return supporter;
}
/**
* @returns {App.Locals['user']}
*/
export function defaultOpponent() {
let opponent = defaultUser();
opponent.status = 5;
opponent.role_id = -1;
opponent.licence = null;
opponent.membership = null;
return opponent;
}
/**
* @returns {App.Types['location']}
*/
export function defaultLocation() {
return {
latitude: 0,
longitude: 0
};
}
/**
* @returns {App.Types['damage']}
*/
export function defaultDamage() {
return {
id: 0,
name: '',
opponent: defaultOpponent(),
driver_id: -1,
insurance: defaultInsurance(),
date: '',
notes: ''
};
}
/**
* @returns {App.Types['insurance']}
*/
export function defaultInsurance() {
return {
id: 0,
company: '',
reference: '',
start_date: '',
end_date: '',
notes: ''
};
}
/**
* @returns {App.Types['car']}
*/
export function defaultCar() {
return {
id: 0,
name: '',
status: 0,
brand: '',
model: '',
price: 0,
rate: 0,
start_date: '',
end_date: '',
color: '',
licence_plate: '',
location: defaultLocation(),
damages: [],
insurances: [],
notes: ''
};
}

View File

@@ -72,7 +72,7 @@ export function isEmpty(obj) {
* @returns string * @returns string
*/ */
export function toRFC3339(dateString) { export function toRFC3339(dateString) {
if (!dateString) dateString = '0001-01-01T00:00:00.000Z'; if (!dateString || dateString == '') dateString = '0001-01-01T00:00:00.000Z';
const date = new Date(dateString); const date = new Date(dateString);
return date.toISOString(); return date.toISOString();
} }
@@ -88,6 +88,31 @@ export function fromRFC3339(dateString) {
return date.toISOString().split('T')[0]; return date.toISOString().split('T')[0];
} }
/**
*
* @param {App.Types['car']} car - The car object to format
*/
export function carDatesFromRFC3339(car) {
car.end_date = fromRFC3339(car.end_date);
car.start_date = fromRFC3339(car.start_date);
car.insurances?.forEach((insurance) => {
insurance.start_date = fromRFC3339(insurance.start_date);
insurance.end_date = fromRFC3339(insurance.end_date);
});
}
/**
*
* @param {App.Types['car']} car - The car object to format
*/
export function carDatesToRFC3339(car) {
car.end_date = toRFC3339(car.end_date);
car.start_date = toRFC3339(car.start_date);
car.insurances?.forEach((insurance) => {
insurance.start_date = toRFC3339(insurance.start_date);
insurance.end_date = toRFC3339(insurance.end_date);
});
}
/** /**
* *
* @param {App.Locals['user']} user - The user object to format * @param {App.Locals['user']} user - The user object to format
@@ -200,3 +225,13 @@ export function refreshCookie(newToken, cookies) {
} }
} }
} }
/**
* checks the permission of the user
* @param {App.Locals['user']} user - The user object
* @param {number} required_permission - The required permission
* @returns {boolean} - True if the user has the required permission
*/
export function hasPrivilige(user, required_permission) {
return user.role_id >= required_permission;
}

View File

@@ -1,45 +1,42 @@
import { defaultBankAccount, defaultMembership } from './defaults';
import { toRFC3339 } from './helpers'; import { toRFC3339 } from './helpers';
/** /**
* Converts FormData to a nested object structure * Converts FormData to a nested object structure
* @param {FormData} formData - The FormData object to convert * @param {FormData} formData - The FormData object to convert
* @returns {{ object: Partial<App.Locals['user']> | Partial<App.Types['subscription']>, password2: string }} Nested object representation of the form data * @returns {{ object: Partial<App.Locals['user']> | Partial<App.Types['subscription']> | Partial<App.Types['car']>, confirm_password: string }} Nested object representation of the form data
*/ */
export function formDataToObject(formData) { export function formDataToObject(formData) {
/** @type { Partial<App.Locals['user']> | Partial<App.Types['subscription']> } */ /** @type { Partial<App.Locals['user']> | Partial<App.Types['subscription']> | Partial<App.Types['car']> } */
const object = {}; const object = {};
let password2 = ''; let confirm_password = '';
console.log('Form data entries:'); console.log('Form data entries:');
for (const [key, value] of formData.entries()) { for (const [key, value] of formData.entries()) {
console.log('Key:', key, 'Value:', value); console.log('Key:', key, 'Value:', value);
if (key == 'password2') { if (key == 'confirm_password') {
password2 = String(value); confirm_password = String(value);
continue; continue;
} }
/** @type {string[]} */ /** @type {string[]} */
const keys = key.match(/\[([^\]]+)\]/g)?.map((k) => k.slice(1, -1)) || [key]; const keys = key.match(/\[([^\]]+)\]/g)?.map((k) => k.slice(1, -1)) || [key];
console.log('Processed keys:', keys);
console.dir(value);
/** @type {Record<string, any>} */ /** @type {Record<string, any>} */
let current = object; let current = object;
// console.log('Current object state:', JSON.stringify(current)); // console.log('Current object state:', JSON.stringify(current));
for (let i = 0; i < keys.length - 1; i++) { for (let i = 0; i < keys.length - 1; i++) {
/** const currentKey = keys[i];
* Create nested object if it doesn't exist const nextKey = keys[i + 1];
* @type {Record<string, any>} const isNextKeyArrayIndex = !isNaN(Number(nextKey));
* @description Ensures proper nesting structure for user data fields if (!current[currentKey]) {
* @example // If next key is a number, initialize an array, otherwise an object
* // For input name="user[membership][status]" current[currentKey] = isNextKeyArrayIndex ? [] : {};
* // Creates: { user: { membership: { status: value } } } }
*/
current[keys[i]] = current[keys[i]] || {};
/** /**
* Move to the next level of the object * Move to the next level of the object
* @type {Record<string, any>} * @type {Record<string, any>}
*/ */
current = current[keys[i]]; current = current[currentKey];
} }
const lastKey = keys[keys.length - 1]; const lastKey = keys[keys.length - 1];
@@ -51,91 +48,108 @@ export function formDataToObject(formData) {
} catch { } catch {
current[lastKey].push(value); current[lastKey].push(value);
} }
} else {
if (Array.isArray(current)) {
// If current is an array, lastKey should be the index
const index = parseInt(lastKey);
current[index] = current[index] || {};
if (keys.length > 2) {
// For nested properties within array elements
const propertyKey = keys[keys.length - 1];
current[index][propertyKey] = value;
} else {
current[index] = value;
}
} else { } else {
current[lastKey] = value; current[lastKey] = value;
} }
} }
}
return { object: object, password2: password2 }; return { object: object, confirm_password: confirm_password };
} }
/** /**
* Processes the raw form data into the expected user data structure * Processes the raw form data into the expected membership data structure
* @param {{ object: Partial<App.Locals['user']>, password2: string} } rawData - The raw form data object * @param { App.Types['membership'] } membership - The raw form data object
* @returns {{ user: Partial<App.Locals['user']> }} Processed user data * @returns {App.Types['membership']} Processed membership data
*/ */
export function processUserFormData(rawData) { export function processMembershipFormData(membership) {
/** @type {{ user: Partial<App.Locals['user']> }} */ return {
let processedData = { id: Number(membership.id) || 0,
user: { status: Number(membership.status),
id: Number(rawData.object.id) || 0, start_date: toRFC3339(String(membership.start_date || '')),
status: Number(rawData.object.status), end_date: toRFC3339(String(membership.end_date || '')),
role_id: Number(rawData.object.role_id), parent_member_id: Number(membership.parent_member_id) || 0,
first_name: String(rawData.object.first_name), subscription: processSubscriptionFormData(membership.subscription)
last_name: String(rawData.object.last_name), };
email: String(rawData.object.email),
phone: String(rawData.object.phone || ''),
company: String(rawData.object.company || ''),
dateofbirth: toRFC3339(String(rawData.object.dateofbirth || '')),
address: String(rawData.object.address || ''),
zip_code: String(rawData.object.zip_code || ''),
city: String(rawData.object.city || ''),
notes: String(rawData.object.notes || ''),
profile_picture: String(rawData.object.profile_picture || ''),
membership: {
id: Number(rawData.object.membership?.id) || 0,
status: Number(rawData.object.membership?.status),
start_date: toRFC3339(String(rawData.object.membership?.start_date || '')),
end_date: toRFC3339(String(rawData.object.membership?.end_date || '')),
parent_member_id: Number(rawData.object.membership?.parent_member_id) || 0,
subscription_model: {
id: Number(rawData.object.membership?.subscription_model?.id) || 0,
name: String(rawData.object.membership?.subscription_model?.name) || '',
details: String(rawData.object.membership?.subscription_model?.details) || '',
conditions: String(rawData.object.membership?.subscription_model?.conditions) || '',
hourly_rate: Number(rawData.object.membership?.subscription_model?.hourly_rate) || 0,
monthly_fee: Number(rawData.object.membership?.subscription_model?.monthly_fee) || 0,
included_hours_per_month:
Number(rawData.object.membership?.subscription_model?.included_hours_per_month) || 0,
included_hours_per_year:
Number(rawData.object.membership?.subscription_model?.included_hours_per_year) || 0
} }
},
licence: { /**
id: Number(rawData.object.licence?.id) || 0, * Processes the raw form data into the expected licence data structure
status: Number(rawData.object.licence?.status), * @param { App.Types['licence'] } licence - The raw form data object
number: String(rawData.object.licence?.number || ''), * @returns {App.Types['licence']} Processed licence data
issued_date: toRFC3339(String(rawData.object.licence?.issued_date || '')), */
expiration_date: toRFC3339(String(rawData.object.licence?.expiration_date || '')), export function processLicenceFormData(licence) {
country: String(rawData.object.licence?.country || ''), return {
categories: rawData.object.licence?.categories || [] id: Number(licence?.id) || 0,
}, status: Number(licence?.status),
number: String(licence?.number || ''),
issued_date: toRFC3339(String(licence?.issued_date || '')),
expiration_date: toRFC3339(String(licence?.expiration_date || '')),
country: String(licence?.country || ''),
categories: licence?.categories || []
};
}
bank_account: { /**
id: Number(rawData.object.bank_account?.id) || 0, * Processes the raw form data into the expected bank_account data structure
account_holder_name: String(rawData.object.bank_account?.account_holder_name || ''), * @param { App.Types['bankAccount'] } bank_account - The raw form data object
bank: String(rawData.object.bank_account?.bank || ''), * @returns {App.Types['bankAccount']} Processed bank_account data
iban: String(rawData.object.bank_account?.iban || ''), */
bic: String(rawData.object.bank_account?.bic || ''), export function processBankAccountFormData(bank_account) {
mandate_reference: String(rawData.object.bank_account?.mandate_reference || ''), {
mandate_date_signed: toRFC3339( return {
String(rawData.object.bank_account?.mandate_date_signed || '') id: Number(bank_account?.id) || 0,
account_holder_name: String(bank_account?.account_holder_name || ''),
bank: String(bank_account?.bank || ''),
iban: String(bank_account?.iban || ''),
bic: String(bank_account?.bic || ''),
mandate_reference: String(bank_account?.mandate_reference || ''),
mandate_date_signed: toRFC3339(String(bank_account?.mandate_date_signed || ''))
};
}
}
/**
* Processes the raw form data into the expected user data structure
* @param { Partial<App.Locals['user']> } user - The raw form data object
* @returns {App.Locals['user']} Processed user data
*/
export function processUserFormData(user) {
/** @type {App.Locals['user']} */
let processedData = {
id: Number(user.id) || 0,
status: Number(user.status),
role_id: Number(user.role_id),
first_name: String(user.first_name),
last_name: String(user.last_name),
password: String(user.password) || '',
email: String(user.email),
phone: String(user.phone || ''),
company: String(user.company || ''),
dateofbirth: toRFC3339(String(user.dateofbirth || '')),
address: String(user.address || ''),
zip_code: String(user.zip_code || ''),
city: String(user.city || ''),
notes: String(user.notes || ''),
membership: processMembershipFormData(user.membership ? user.membership : defaultMembership()),
licence: user.licence ? processLicenceFormData(user.licence) : null,
bank_account: processBankAccountFormData(
user.bank_account ? user.bank_account : defaultBankAccount()
) )
}
}
}; };
console.log('Categories: --------'); // console.log('Categories: --------');
console.dir(rawData.object.licence); // console.dir(rawData.object.licence);
if (
rawData.object.password &&
rawData.password2 &&
rawData.object.password === rawData.password2 &&
rawData.object.password.trim() !== ''
) {
processedData.user.password = rawData.object.password;
}
const clean = JSON.parse(JSON.stringify(processedData), (key, value) => const clean = JSON.parse(JSON.stringify(processedData), (key, value) =>
value !== null && value !== '' ? value : undefined value !== null && value !== '' ? value : undefined
); );
@@ -144,23 +158,21 @@ export function processUserFormData(rawData) {
} }
/** /**
* Processes the raw form data into the expected user data structure * Processes the raw form data into the expected subscription data structure
* @param {{ object: Partial<App.Types['subscription']>} } rawData - The raw form data object * @param {Partial<App.Types['subscription']>} subscription - The raw form data object
* @returns {{ subscription: Partial<App.Types['subscription']> }} Processed user data * @returns {App.Types['subscription']} Processed user data
*/ */
export function processSubscriptionFormData(rawData) { export function processSubscriptionFormData(subscription) {
/** @type {{ subscription: Partial<App.Types['subscription']> }} */ /** @type {Partial<App.Types['subscription']>} */
let processedData = { let processedData = {
subscription: { id: Number(subscription.id) || 0,
id: Number(rawData.object.id) || 0, name: String(subscription.name) || '',
name: String(rawData.object.name) || '', details: String(subscription.details) || '',
details: String(rawData.object.details) || '', conditions: String(subscription.conditions) || '',
conditions: String(rawData.object.conditions) || '', hourly_rate: Number(subscription.hourly_rate) || 0,
hourly_rate: Number(rawData.object.hourly_rate) || 0, monthly_fee: Number(subscription.monthly_fee) || 0,
monthly_fee: Number(rawData.object.monthly_fee) || 0, included_hours_per_month: Number(subscription.included_hours_per_month) || 0,
included_hours_per_month: Number(rawData.object.included_hours_per_month) || 0, included_hours_per_year: Number(subscription.included_hours_per_year) || 0
included_hours_per_year: Number(rawData.object.included_hours_per_year) || 0
}
}; };
const clean = JSON.parse(JSON.stringify(processedData), (key, value) => const clean = JSON.parse(JSON.stringify(processedData), (key, value) =>
value !== null && value !== '' ? value : undefined value !== null && value !== '' ? value : undefined
@@ -168,3 +180,85 @@ export function processSubscriptionFormData(rawData) {
console.dir(clean); console.dir(clean);
return clean; return clean;
} }
/**
* Processes the raw form data into the expected insurance data structure
* @param {App.Types['insurance']} insurance - The raw form data object
* @returns {App.Types['insurance']} Processed user data
*/
export function processInsuranceFormData(insurance) {
return {
id: Number(insurance.id) || 0,
company: String(insurance.company) || '',
reference: String(insurance.reference) || '',
start_date: toRFC3339(String(insurance.start_date) || '') || '',
end_date: toRFC3339(String(insurance.end_date) || '') || '',
notes: String(insurance.notes) || ''
};
}
/**
* Processes the raw form data into the expected car data structure
* @param {Partial<App.Types['car']>} car - The raw form data object
* @returns {App.Types['car']} Processed user data
*/
export function processCarFormData(car) {
console.dir(car);
/** @type {App.Types['car']} */
let processedData = {
id: Number(car.id) || 0,
name: String(car.name) || '',
status: Number(car.status) || 0,
brand: String(car.brand) || '',
model: String(car.model) || '',
price: Number(car.price) || 0,
rate: Number(car.rate) || 0,
licence_plate: String(car.licence_plate),
start_date: 'start_date' in car ? toRFC3339(String(car.start_date) || '') : '',
end_date: 'end_date' in car ? toRFC3339(String(car.end_date) || '') : '',
color: String(car.color) || '',
notes: String(car.notes) || '',
location:
'location' in car
? {
latitude: Number(car.location?.latitude) || 0,
longitude: Number(car.location?.longitude) || 0
}
: {
latitude: 0,
longitude: 0
},
damages: /** @type {App.Types['damage'][]} */ ([]),
insurances: /** @type {App.Types['insurance'][]} */ ([])
};
car.insurances?.forEach((insurance) => {
processedData.insurances.push(processInsuranceFormData(insurance));
});
car.damages?.forEach((damage) => {
console.dir(damage);
processedData.damages.push(processDamageFormData(damage));
});
const clean = JSON.parse(JSON.stringify(processedData), (key, value) =>
value !== null && value !== '' ? value : undefined
);
console.dir(clean);
return clean;
}
/**
* Processes the raw form data into the expected damage data structure
* @param { App.Types['damage'] } damage - The raw form data object
* @returns {App.Types['damage']} Processed damage data
*/
export function processDamageFormData(damage) {
return {
id: Number(damage.id) || 0,
name: String(damage.name) || '',
opponent: processUserFormData(damage.opponent),
driver_id: Number(damage.driver_id) || 0,
insurance: processInsuranceFormData(damage.insurance),
date: toRFC3339(String(damage.date) || ''),
notes: String(damage.notes) || ''
};
}

View File

@@ -6,8 +6,8 @@
<!-- <div class="hero-logo"><img src={Developer} alt="Alexander Stölting" /></div> --> <!-- <div class="hero-logo"><img src={Developer} alt="Alexander Stölting" /></div> -->
<h3 class="hero-subtitle subtitle">Backend vom Carsharing Zeug</h3> <h3 class="hero-subtitle subtitle">Backend vom Carsharing Zeug</h3>
<div class="hero-buttons-container"> <div class="hero-buttons-container">
<a class="button-dark" href="https://tiny-bits.net/" data-learn-more <a class="button-dark" href="https://carsharing-hasloh.de/" data-learn-more
>Auf zu Tiny Bits</a >Auf zur Carsharing Webseite</a
> >
</div> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@ import { BASE_API_URI } from '$lib/utils/constants';
import { formatError, userDatesFromRFC3339 } from '$lib/utils/helpers'; import { formatError, userDatesFromRFC3339 } from '$lib/utils/helpers';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { formDataToObject, processUserFormData } from '$lib/utils/processing'; import { formDataToObject, processUserFormData } from '$lib/utils/processing';
import { base } from '$app/paths';
/** /**
* @typedef {Object} UpdateData * @typedef {Object} UpdateData
@@ -12,7 +13,7 @@ import { formDataToObject, processUserFormData } from '$lib/utils/processing';
export async function load({ locals, params }) { export async function load({ locals, params }) {
// redirect user if not logged in // redirect user if not logged in
if (!locals.user) { if (!locals.user) {
throw redirect(302, `/auth/login?next=/auth/about/${params.id}`); throw redirect(302, `${base}/auth/login?next=${base}/auth/about/${params.id}`);
} }
} }
@@ -29,18 +30,29 @@ export const actions = {
updateUser: async ({ request, fetch, cookies, locals }) => { updateUser: async ({ request, fetch, cookies, locals }) => {
let formData = await request.formData(); let formData = await request.formData();
const rawData = formDataToObject(formData); const rawFormData = formDataToObject(formData);
const processedData = processUserFormData(rawData); /** @type {{object: Partial<App.Locals['user']>, confirm_password: string}} */
const rawData = {
object: /** @type {Partial<App.Locals['user']>} */ (rawFormData.object),
confirm_password: rawFormData.confirm_password
};
// confirm password matches and is not empty. Otherwise set password to empty string
if (
rawData.object.password &&
rawData.confirm_password &&
(rawData.object.password != rawData.confirm_password || rawData.object.password.trim() == '')
) {
rawData.object.password = '';
}
const processedData = processUserFormData(rawData.object);
const isCreating = !processedData.user.id || processedData.user.id === 0; // const isCreating = !processedData.user.id || processedData.user.id === 0;
console.log('Is creating: ', isCreating); // console.log('Is creating: ', isCreating);
// console.dir(formData); const apiURL = `${BASE_API_URI}/auth/users/`;
console.dir(processedData.user.membership);
const apiURL = `${BASE_API_URI}/backend/users/`;
/** @type {RequestInit} */ /** @type {RequestInit} */
const requestUpdateOptions = { const requestUpdateOptions = {
method: 'PATCH', method: 'PUT',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -59,7 +71,7 @@ export const actions = {
const response = await res.json(); const response = await res.json();
locals.user = response; locals.user = response;
userDatesFromRFC3339(locals.user); userDatesFromRFC3339(locals.user);
throw redirect(303, `/auth/about/${response.id}`); throw redirect(303, `${base}/auth/about/${response.id}`);
}, },
/** /**

View File

@@ -36,7 +36,7 @@
default: 'unknown status' default: 'unknown status'
})}</span })}</span
> >
<span>{$t(`userRole.${user.role_id}`, { default: 'unknown role' })}</span> <span>{$t(`userRole.${user.role_id}`, { default: 'unknown' })}</span>
</span> </span>
</h3> </h3>
{/if} {/if}
@@ -93,7 +93,7 @@
{licence_categories} {licence_categories}
on:close={close} on:close={close}
on:cancel={close} on:cancel={close}
role_id={user.role_id} editor={user}
/> />
</Modal> </Modal>
{/if} {/if}

View File

@@ -2,6 +2,7 @@
export async function load({ data }) { export async function load({ data }) {
return { return {
users: data.users, users: data.users,
user: data.user user: data.user,
cars: data.cars
}; };
} }

View File

@@ -1,47 +1,69 @@
import { BASE_API_URI } from '$lib/utils/constants'; import { BASE_API_URI } from '$lib/utils/constants';
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { userDatesFromRFC3339, refreshCookie } from '$lib/utils/helpers'; import { userDatesFromRFC3339, refreshCookie, carDatesFromRFC3339 } from '$lib/utils/helpers';
import { base } from '$app/paths';
/** @type {import('./$types').LayoutServerLoad} */ /** @type {import('./$types').LayoutServerLoad} */
export async function load({ cookies, fetch, locals }) { export async function load({ cookies, fetch, locals }) {
const jwt = cookies.get('jwt'); const jwt = cookies.get('jwt');
try { try {
const response = await fetch(`${BASE_API_URI}/backend/users/all`, { const [usersResponse, carsResponse] = await Promise.all([
fetch(`${BASE_API_URI}/auth/users`, {
credentials: 'include', credentials: 'include',
headers: { headers: { Cookie: `jwt=${jwt}` }
Cookie: `jwt=${jwt}` }),
} fetch(`${BASE_API_URI}/auth/cars`, {
}); credentials: 'include',
if (!response.ok) { headers: { Cookie: `jwt=${jwt}` }
// Clear the invalid JWT cookie })
]);
if (!usersResponse.ok || !carsResponse.ok) {
cookies.delete('jwt', { path: '/' }); cookies.delete('jwt', { path: '/' });
throw redirect(302, '/auth/login?next=admin/users/'); throw redirect(302, `${base}/auth/login?next=${base}/auth/admin/users/`);
} }
const [usersData, carsData] = await Promise.all([usersResponse.json(), carsResponse.json()]);
// const response = await fetch(`${BASE_API_URI}/auth/users/`, {
// credentials: 'include',
// headers: {
// Cookie: `jwt=${jwt}`
// }
// });
// if (!response.ok) {
// // Clear the invalid JWT cookie
// cookies.delete('jwt', { path: '/' });
// throw redirect(302, `${base}/auth/login?next=${base}/auth/admin/users/`);
// }
const data = await response.json(); // const data = await response.json();
// Check if the server sent a new token
const newToken = response.headers.get('Set-Cookie');
refreshCookie(newToken, cookies);
/** @type {App.Locals['users']}*/ /** @type {App.Locals['users']}*/
const users = data.users; const users = usersData.users;
/** @type {App.Types['car'][]} */
const cars = carsData.cars;
users.forEach((user) => { users.forEach((user) => {
userDatesFromRFC3339(user); userDatesFromRFC3339(user);
}); });
cars.forEach((car) => {
carDatesFromRFC3339(car);
});
locals.users = users; locals.users = users;
locals.cars = cars;
// Check if the server sent a new token
const newToken = usersResponse.headers.get('Set-Cookie');
refreshCookie(newToken, cookies);
return { return {
subscriptions: locals.subscriptions, subscriptions: locals.subscriptions,
licence_categories: locals.licence_categories, licence_categories: locals.licence_categories,
users: locals.users, users: locals.users,
user: locals.user user: locals.user,
cars: locals.cars
}; };
} catch (error) { } catch (error) {
console.error('Error fetching data:', error); console.error('Error fetching data:', error);
// In case of any error, clear the JWT cookie // In case of any error, clear the JWT cookie
cookies.delete('jwt', { path: '/' }); cookies.delete('jwt', { path: '/' });
throw redirect(302, '/auth/login?next=admin/users/'); throw redirect(302, `${base}/auth/login?next=${base}/auth/admin/users/`);
} }
} }

View File

@@ -2,20 +2,25 @@
// - Implement a load function to fetch a list of all users. // - Implement a load function to fetch a list of all users.
// - Create actions for updating user information (similar to the about/[id] route). // - Create actions for updating user information (similar to the about/[id] route).
import { BASE_API_URI } from '$lib/utils/constants'; import { BASE_API_URI, PERMISSIONS } from '$lib/utils/constants';
import { formatError, userDatesFromRFC3339 } from '$lib/utils/helpers'; import { formatError, hasPrivilige, userDatesFromRFC3339 } from '$lib/utils/helpers';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
import { import {
formDataToObject, formDataToObject,
processCarFormData,
processSubscriptionFormData, processSubscriptionFormData,
processUserFormData processUserFormData
} from '$lib/utils/processing'; } from '$lib/utils/processing';
import { base } from '$app/paths';
/** @type {import('./$types').PageServerLoad} */ /** @type {import('./$types').PageServerLoad} */
export async function load({ locals }) { export async function load({ locals }) {
// redirect user if not logged in // redirect user if not logged in
if (!locals.user) { if (!locals.user) {
throw redirect(302, `/auth/login?next=/auth/admin/users`); throw redirect(302, `${base}/auth/login?next=${base}/auth/admin/users`);
}
if (!hasPrivilige(locals.user, PERMISSIONS.View)) {
throw redirect(302, `${base}/auth/about/${locals.user.id}`);
} }
} }
@@ -32,23 +37,36 @@ export const actions = {
updateUser: async ({ request, fetch, cookies, locals }) => { updateUser: async ({ request, fetch, cookies, locals }) => {
let formData = await request.formData(); let formData = await request.formData();
const rawData = formDataToObject(formData); const rawFormData = formDataToObject(formData);
const processedData = processUserFormData(rawData); /** @type {{object: App.Locals['user'], confirm_password: string}} */
const rawData = {
object: /** @type {App.Locals['user']} */ (rawFormData.object),
confirm_password: rawFormData.confirm_password
};
// confirm password matches and is not empty. Otherwise set password to empty string
if (
rawData.object.password &&
rawData.confirm_password &&
(rawData.object.password != rawData.confirm_password || rawData.object.password.trim() == '')
) {
rawData.object.password = '';
}
const user = processUserFormData(rawData.object);
console.dir(processedData.user.membership); console.dir(user.membership);
const isCreating = !processedData.user.id || processedData.user.id === 0; const isCreating = !user.id || user.id === 0;
console.log('Is creating: ', isCreating); console.log('Is creating: ', isCreating);
const apiURL = `${BASE_API_URI}/backend/users`; const apiURL = `${BASE_API_URI}/auth/users`;
/** @type {RequestInit} */ /** @type {RequestInit} */
const requestOptions = { const requestOptions = {
method: isCreating ? 'POST' : 'PATCH', method: isCreating ? 'POST' : 'PUT',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Cookie: `jwt=${cookies.get('jwt')}` Cookie: `jwt=${cookies.get('jwt')}`
}, },
body: JSON.stringify(processedData) body: JSON.stringify(user)
}; };
const res = await fetch(apiURL, requestOptions); const res = await fetch(apiURL, requestOptions);
@@ -63,7 +81,7 @@ export const actions = {
console.log('Server success response:', response); console.log('Server success response:', response);
locals.user = response; locals.user = response;
userDatesFromRFC3339(locals.user); userDatesFromRFC3339(locals.user);
throw redirect(303, `/auth/admin/users`); throw redirect(303, `${base}/auth/admin/users`);
}, },
/** /**
@@ -77,22 +95,23 @@ export const actions = {
updateSubscription: async ({ request, fetch, cookies }) => { updateSubscription: async ({ request, fetch, cookies }) => {
let formData = await request.formData(); let formData = await request.formData();
const rawData = formDataToObject(formData); const rawFormData = formDataToObject(formData);
const processedData = processSubscriptionFormData(rawData); const rawSubscription = /** @type {Partial<App.Types['subscription']>} */ (rawFormData.object);
const subscription = processSubscriptionFormData(rawSubscription);
const isCreating = !processedData.subscription.id || processedData.subscription.id === 0; const isCreating = !subscription.id || subscription.id === 0;
console.log('Is creating: ', isCreating); console.log('Is creating: ', isCreating);
const apiURL = `${BASE_API_URI}/backend/membership/subscriptions`; const apiURL = `${BASE_API_URI}/auth/subscriptions`;
/** @type {RequestInit} */ /** @type {RequestInit} */
const requestOptions = { const requestOptions = {
method: isCreating ? 'POST' : 'PATCH', method: isCreating ? 'POST' : 'PUT',
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Cookie: `jwt=${cookies.get('jwt')}` Cookie: `jwt=${cookies.get('jwt')}`
}, },
body: JSON.stringify(processedData) body: JSON.stringify(subscription)
}; };
const res = await fetch(apiURL, requestOptions); const res = await fetch(apiURL, requestOptions);
@@ -105,7 +124,52 @@ export const actions = {
const response = await res.json(); const response = await res.json();
console.log('Server success response:', response); console.log('Server success response:', response);
throw redirect(303, `/auth/admin/users`); throw redirect(303, `${base}/auth/admin/users`);
},
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @returns Error data or redirects user to the home page or the previous page
*/
updateCar: async ({ request, fetch, cookies }) => {
let formData = await request.formData();
console.dir(formData);
const rawCar = /**@type {Partial<App.Types['car']>} */ (formDataToObject(formData).object);
const car = processCarFormData(rawCar);
const isCreating = !car.id || car.id === 0;
console.log('Is creating: ', isCreating);
console.log('sending: ', JSON.stringify(car.damages));
const apiURL = `${BASE_API_URI}/auth/cars`;
/** @type {RequestInit} */
const requestOptions = {
method: isCreating ? 'POST' : 'PUT',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Cookie: `jwt=${cookies.get('jwt')}`
},
body: JSON.stringify(car)
};
const res = await fetch(apiURL, requestOptions);
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.errors);
return fail(400, { errors: errors });
}
const response = await res.json();
console.log('Server success response:', response);
console.log('Server opponent response:', response.damages[0]?.opponent);
console.log('Server insurance response:', response.damages[0]?.insurance);
throw redirect(303, `${base}/auth/admin/users`);
}, },
/** /**
@@ -119,10 +183,11 @@ export const actions = {
userDelete: async ({ request, fetch, cookies }) => { userDelete: async ({ request, fetch, cookies }) => {
let formData = await request.formData(); let formData = await request.formData();
const rawData = formDataToObject(formData); const rawFormData = formDataToObject(formData);
const processedData = processUserFormData(rawData); /** @type {Partial<App.Locals['user']>} */
const rawUser = /** @type {Partial<App.Locals['user']>} */ (rawFormData.object);
const apiURL = `${BASE_API_URI}/backend/users`; const apiURL = `${BASE_API_URI}/auth/users`;
/** @type {RequestInit} */ /** @type {RequestInit} */
const requestOptions = { const requestOptions = {
@@ -132,7 +197,7 @@ export const actions = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Cookie: `jwt=${cookies.get('jwt')}` Cookie: `jwt=${cookies.get('jwt')}`
}, },
body: JSON.stringify(processedData) body: JSON.stringify({ id: Number(rawUser.id) })
}; };
const res = await fetch(apiURL, requestOptions); const res = await fetch(apiURL, requestOptions);
@@ -145,7 +210,7 @@ export const actions = {
const response = await res.json(); const response = await res.json();
console.log('Server success response:', response); console.log('Server success response:', response);
throw redirect(303, `/auth/admin/users`); throw redirect(303, `${base}/auth/admin/users`);
}, },
/** /**
@@ -153,16 +218,17 @@ export const actions = {
* @param request - The request object * @param request - The request object
* @param fetch - Fetch object from sveltekit * @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object * @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current subscription
* @returns * @returns
*/ */
subscriptionDelete: async ({ request, fetch, cookies }) => { subscriptionDelete: async ({ request, fetch, cookies }) => {
let formData = await request.formData(); let formData = await request.formData();
const rawData = formDataToObject(formData); const rawData = formDataToObject(formData);
const processedData = processSubscriptionFormData(rawData);
const apiURL = `${BASE_API_URI}/backend/membership/subscriptions`; /** @type {Partial<App.Types['subscription']>} */
const subscription = rawData.object;
const apiURL = `${BASE_API_URI}/auth/subscriptions`;
/** @type {RequestInit} */ /** @type {RequestInit} */
const requestOptions = { const requestOptions = {
@@ -172,6 +238,85 @@ export const actions = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Cookie: `jwt=${cookies.get('jwt')}` Cookie: `jwt=${cookies.get('jwt')}`
}, },
body: JSON.stringify({ id: Number(subscription.id), name: subscription.name })
};
const res = await fetch(apiURL, requestOptions);
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.errors);
return fail(400, { errors: errors });
}
const response = await res.json();
console.log('Server success response:', response);
throw redirect(303, `${base}/auth/admin/users`);
},
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @returns
*/
carDelete: async ({ request, fetch, cookies }) => {
let formData = await request.formData();
console.dir(formData);
const rawCar = formDataToObject(formData);
const apiURL = `${BASE_API_URI}/auth/cars`;
console.log('sending delete request to', JSON.stringify({ id: Number(rawCar.object.id) }));
/** @type {RequestInit} */
const requestOptions = {
method: 'DELETE',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Cookie: `jwt=${cookies.get('jwt')}`
},
body: JSON.stringify({ id: Number(rawCar.object.id) })
};
const res = await fetch(apiURL, requestOptions);
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.errors);
return fail(400, { errors: errors });
}
const response = await res.json();
console.log('Server success response:', response);
throw redirect(303, `${base}/auth/admin/users`);
},
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @returns
*/
grantBackendAccess: async ({ request, fetch, cookies }) => {
let formData = await request.formData();
const rawFormData = formDataToObject(formData);
/** @type {App.Locals['user']} */
const rawUser = /** @type {App.Locals['user']} */ (rawFormData.object);
const processedData = processUserFormData(rawUser);
console.dir(processedData);
const apiURL = `${BASE_API_URI}/auth/users/activate`;
/** @type {RequestInit} */
const requestOptions = {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Cookie: `jwt=${cookies.get('jwt')}`
},
body: JSON.stringify(processedData) body: JSON.stringify(processedData)
}; };
@@ -185,6 +330,6 @@ export const actions = {
const response = await res.json(); const response = await res.json();
console.log('Server success response:', response); console.log('Server success response:', response);
throw redirect(303, `/auth/admin/users`); throw redirect(303, `${base}/auth/admin/users`);
} }
}; };

View File

@@ -6,7 +6,15 @@
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { applyAction, enhance } from '$app/forms'; import { applyAction, enhance } from '$app/forms';
import { receive, send } from '$lib/utils/helpers'; import { hasPrivilige, receive, send } from '$lib/utils/helpers';
import { PERMISSIONS } from '$lib/utils/constants';
import {
defaultCar,
defaultSubscription,
defaultSupporter,
defaultUser
} from '$lib/utils/defaults';
import CarEditForm from '$lib/components/CarEditForm.svelte';
/** @type {import('./$types').ActionData} */ /** @type {import('./$types').ActionData} */
export let form; export let form;
@@ -14,23 +22,34 @@
$: ({ $: ({
user = [], user = [],
users = [], users = [],
cars = [],
licence_categories = [], licence_categories = [],
subscriptions = [], subscriptions = [],
payments = [] payments = []
} = $page.data); } = $page.data);
let activeSection = 'users'; let activeSection = 'members';
/** @type{App.Locals['user'] | null} */
let selectedUser = null; /** @type{App.Types['car'] | App.Types['subscription'] | App.Locals['user'] | null} */
/** @type{App.Types['subscription'] | null} */ let selected = null;
let selectedSubscription = null;
let showSubscriptionModal = false;
let showUserModal = false;
let searchTerm = ''; let searchTerm = '';
$: filteredUsers = searchTerm ? getFilteredUsers() : users; $: members = users.filter((/** @type{App.Locals['user']} */ user) => {
return user.role_id >= PERMISSIONS.Member;
});
$: supporters = users.filter((/** @type{App.Locals['user']} */ user) => {
return user.role_id < PERMISSIONS.Member;
});
$: filteredMembers = searchTerm ? getFilteredUsers(members) : members;
function handleMailButtonClick() { $: filteredSupporters = searchTerm ? getFilteredUsers(supporters) : supporters;
/**
* Handles Mail button click to open a formatted mailto link
* @param {App.Locals['user'][]} filteredUsers - the users to send the mail to
*/
function handleMailButtonClick(filteredUsers) {
const subject = 'Important Announcement'; const subject = 'Important Announcement';
const body = `Hello everyone,\n\nThis is an important message.`; const body = `Hello everyone,\n\nThis is an important message.`;
const bccEmails = filteredUsers const bccEmails = filteredUsers
@@ -43,14 +62,15 @@
} }
/** /**
* returns a set of users depending on the entered search query * returns a set of members depending on the entered search query
* @param {App.Locals['user'][]} userSet Set to filter
* @return {App.Locals['user'][]}*/ * @return {App.Locals['user'][]}*/
const getFilteredUsers = () => { const getFilteredUsers = (userSet) => {
if (!searchTerm.trim()) return users; if (!searchTerm.trim()) return userSet;
const term = searchTerm.trim().toLowerCase(); const term = searchTerm.trim().toLowerCase();
return users.filter((/** @type{App.Locals['user']}*/ user) => { return userSet.filter((/** @type{App.Locals['user']}*/ user) => {
const basicMatch = [ const basicMatch = [
user.first_name?.toLowerCase(), user.first_name?.toLowerCase(),
user.last_name?.toLowerCase(), user.last_name?.toLowerCase(),
@@ -63,9 +83,7 @@
user.licence?.number?.toLowerCase() user.licence?.number?.toLowerCase()
].some((field) => field?.includes(term)); ].some((field) => field?.includes(term));
const subscriptionMatch = user.membership?.subscription_model?.name const subscriptionMatch = user.membership?.subscription?.name?.toLowerCase().includes(term);
?.toLowerCase()
.includes(term);
const licenceCategoryMatch = user.licence?.categories?.some((cat) => const licenceCategoryMatch = user.licence?.categories?.some((cat) =>
cat.category.toLowerCase().includes(term) cat.category.toLowerCase().includes(term)
@@ -80,29 +98,8 @@
}); });
}; };
/**
* Opens the edit modal for the selected user.
* @param {App.Locals['user'] | null} user The user to edit.
*/
const openEditUserModal = (user) => {
selectedUser = user;
showUserModal = true;
};
/**
* Opens the edit modal for the selected subscription.
* @param {App.Types['subscription'] | null} subscription The user to edit.
*/
const openEditSubscriptionModal = (subscription) => {
selectedSubscription = subscription;
showSubscriptionModal = true;
};
const close = () => { const close = () => {
showUserModal = false; selected = null;
showSubscriptionModal = false;
selectedUser = null;
selectedSubscription = null;
if (form) { if (form) {
form.errors = []; form.errors = [];
} }
@@ -124,12 +121,22 @@
<ul class="nav-list"> <ul class="nav-list">
<li> <li>
<button <button
class="nav-link {activeSection === 'users' ? 'active' : ''}" class="nav-link {activeSection === 'members' ? 'active' : ''}"
on:click={() => setActiveSection('users')} on:click={() => setActiveSection('members')}
> >
<i class="fas fa-users"></i> <i class="fas fa-users"></i>
{$t('users')} {$t('users')}
<span class="nav-badge">{users.length}</span> <span class="nav-badge">{members.length}</span>
</button>
</li>
<li>
<button
class="nav-link {activeSection === 'supporter' ? 'active' : ''}"
on:click={() => setActiveSection('supporter')}
>
<i class="fas fa-hand-holding-dollar"></i>
{$t('supporter')}
<span class="nav-badge">{supporters.length}</span>
</button> </button>
</li> </li>
<li> <li>
@@ -138,10 +145,20 @@
on:click={() => setActiveSection('subscriptions')} on:click={() => setActiveSection('subscriptions')}
> >
<i class="fas fa-clipboard-list"></i> <i class="fas fa-clipboard-list"></i>
{$t('subscription.subscriptions')} {$t('subscriptions.subscriptions')}
<span class="nav-badge">{subscriptions.length}</span> <span class="nav-badge">{subscriptions.length}</span>
</button> </button>
</li> </li>
<li>
<button
class="nav-link {activeSection === 'cars' ? 'active' : ''}"
on:click={() => setActiveSection('cars')}
>
<i class="fas fa-car"></i>
{$t('cars')}
<span class="nav-badge">{cars.length}</span>
</button>
</li>
<li> <li>
<button <button
class="nav-link {activeSection === 'payments' ? 'active' : ''}" class="nav-link {activeSection === 'payments' ? 'active' : ''}"
@@ -168,7 +185,7 @@
{/each} {/each}
{/if} {/if}
{#if activeSection === 'users'} {#if activeSection === 'members'}
<div class="section-header"> <div class="section-header">
<h2>{$t('users')}</h2> <h2>{$t('users')}</h2>
<div class="title-container"> <div class="title-container">
@@ -183,20 +200,25 @@
<button <button
class="btn primary" class="btn primary"
aria-label="Mail Users" aria-label="Mail Users"
on:click={() => handleMailButtonClick()} on:click={() => handleMailButtonClick(filteredMembers)}
> >
<i class="fas fa-envelope"></i> <i class="fas fa-envelope"></i>
</button> </button>
</div> </div>
<div> <div>
<button class="btn primary" on:click={() => openEditUserModal(null)}> <button
class="btn primary"
on:click={() => {
selected = defaultUser();
}}
>
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{$t('add_new')} {$t('add_new')}
</button> </button>
</div> </div>
</div> </div>
<div class="accordion"> <div class="accordion">
{#each filteredUsers as user} {#each filteredMembers as user}
<details class="accordion-item"> <details class="accordion-item">
<summary class="accordion-header"> <summary class="accordion-header">
{user.first_name} {user.first_name}
@@ -210,7 +232,42 @@
<td>{user.id}</td> <td>{user.id}</td>
</tr> </tr>
<tr> <tr>
<th>{$t('user.name')}</th> <td>
<form
method="POST"
action="?/grantBackendAccess"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'success' || result.type === 'redirect') {
await applyAction(result);
}
};
}}
on:submit|preventDefault={(/** @type {SubmitEvent} */ e) => {
if (
!confirm(
$t('dialog.backend_access', {
values: {
firstname: user.first_name || '',
lastname: user.last_name || ''
}
})
)
) {
e.preventDefault(); // Cancel form submission if user declines
}
}}
>
<input type="hidden" name="user[id]" value={user.id} />
<button class="button-dark" type="submit">
<i class="fas fa-unlock-keyhole"></i>
{$t('grant_backend_access')}
</button>
</form>
</td>
</tr>
<tr>
<th>{$t('name')}</th>
<td>{user.first_name} {user.last_name}</td> <td>{user.first_name} {user.last_name}</td>
</tr> </tr>
<tr> <tr>
@@ -218,8 +275,8 @@
<td>{user.email}</td> <td>{user.email}</td>
</tr> </tr>
<tr> <tr>
<th>{$t('subscription.subscription')}</th> <th>{$t('subscriptions.subscription')}</th>
<td>{user.membership?.subscription_model?.name}</td> <td>{user.membership?.subscription?.name}</td>
</tr> </tr>
<tr> <tr>
<th>{$t('status')}</th> <th>{$t('status')}</th>
@@ -228,7 +285,12 @@
</tbody> </tbody>
</table> </table>
<div class="button-group"> <div class="button-group">
<button class="btn primary" on:click={() => openEditUserModal(user)}> <button
class="btn primary"
on:click={() => {
selected = user;
}}
>
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
{$t('edit')} {$t('edit')}
</button> </button>
@@ -269,11 +331,126 @@
</details> </details>
{/each} {/each}
</div> </div>
{:else if activeSection === 'supporter'}
<div class="section-header">
<h2>{$t('supporter')}</h2>
<div class="title-container">
<InputField
name="search"
bind:value={searchTerm}
placeholder={$t('placeholder.search')}
backgroundColor="--base"
/>
</div>
<div>
<button
class="btn primary"
aria-label="Mail Supporter"
on:click={() => handleMailButtonClick(filteredSupporters)}
>
<i class="fas fa-envelope"></i>
</button>
</div>
<div>
<button
class="btn primary"
on:click={() => {
selected = defaultSupporter();
}}
>
<i class="fas fa-plus"></i>
{$t('add_new')}
</button>
</div>
</div>
<div class="accordion">
{#each filteredSupporters as user}
<details class="accordion-item">
<summary class="accordion-header">
{user.company}
</summary>
<div class="accordion-content">
<table class="table">
<tbody>
<tr>
<th>{$t('name')}</th>
<td>{user.first_name} {user.last_name}</td>
</tr>
<tr>
<th>{$t('phone')}</th>
<td>{user.phone}</td>
</tr>
<tr>
<th>{$t('user.email')}</th>
<td>{user.email}</td>
</tr>
<tr>
<th>{$t('status')}</th>
<td>{$t('userStatus.' + user.status)}</td>
</tr>
</tbody>
</table>
<div class="button-group">
{#if hasPrivilige(user, PERMISSIONS.Update)}
<button
class="btn primary"
on:click={() => {
selected = user;
}}
>
<i class="fas fa-edit"></i>
{$t('edit')}
</button>
{/if}
{#if hasPrivilige(user, PERMISSIONS.Delete)}
<form
method="POST"
action="?/userDelete"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'success' || result.type === 'redirect') {
await applyAction(result);
}
};
}}
on:submit|preventDefault={(/** @type {SubmitEvent} */ e) => {
if (
!confirm(
$t('dialog.user_deletion', {
values: {
firstname: user.first_name || '',
lastname: user.last_name || ''
}
})
)
) {
e.preventDefault(); // Cancel form submission if user declines
}
}}
>
<input type="hidden" name="user[id]" value={user.id} />
<input type="hidden" name="user[last_name]" value={user.last_name} />
<button class="btn danger" type="submit">
<i class="fas fa-trash"></i>
{$t('delete')}
</button>
</form>
{/if}
</div>
</div>
</details>
{/each}
</div>
{:else if activeSection === 'subscriptions'} {:else if activeSection === 'subscriptions'}
<div class="section-header"> <div class="section-header">
<h2>{$t('subscription.subscriptions')}</h2> <h2>{$t('subscriptions.subscriptions')}</h2>
{#if user.role_id == 8} {#if hasPrivilige(user, PERMISSIONS.Super)}
<button class="btn primary" on:click={() => openEditSubscriptionModal(null)}> <button
class="btn primary"
on:click={() => {
selected = defaultSubscription();
}}
>
<i class="fas fa-plus"></i> <i class="fas fa-plus"></i>
{$t('add_new')} {$t('add_new')}
</button> </button>
@@ -285,9 +462,9 @@
<summary class="accordion-header"> <summary class="accordion-header">
{subscription.name} {subscription.name}
<span class="nav-badge" <span class="nav-badge"
>{users.filter( >{members.filter(
(/** @type{App.Locals['user']}*/ user) => (/** @type{App.Locals['user']}*/ user) =>
user.membership?.subscription_model?.name === subscription.name user.membership?.subscription?.name === subscription.name
).length}</span ).length}</span
> >
</summary> </summary>
@@ -295,7 +472,7 @@
<table class="table"> <table class="table">
<tbody> <tbody>
<tr> <tr>
<th>{$t('subscription.monthly_fee')}</th> <th>{$t('subscriptions.monthly_fee')}</th>
<td <td
>{subscription.monthly_fee !== -1 >{subscription.monthly_fee !== -1
? subscription.monthly_fee + '€' ? subscription.monthly_fee + '€'
@@ -303,7 +480,7 @@
> >
</tr> </tr>
<tr> <tr>
<th>{$t('subscription.hourly_rate')}</th> <th>{$t('subscriptions.hourly_rate')}</th>
<td <td
>{subscription.hourly_rate !== -1 >{subscription.hourly_rate !== -1
? subscription.hourly_rate + '€' ? subscription.hourly_rate + '€'
@@ -311,11 +488,11 @@
> >
</tr> </tr>
<tr> <tr>
<th>{$t('subscription.included_hours_per_year')}</th> <th>{$t('subscriptions.included_hours_per_year')}</th>
<td>{subscription.included_hours_per_year || 0}</td> <td>{subscription.included_hours_per_year || 0}</td>
</tr> </tr>
<tr> <tr>
<th>{$t('subscription.included_hours_per_month')}</th> <th>{$t('subscriptions.included_hours_per_month')}</th>
<td>{subscription.included_hours_per_month || 0}</td> <td>{subscription.included_hours_per_month || 0}</td>
</tr> </tr>
<tr> <tr>
@@ -323,21 +500,24 @@
<td>{subscription.details || '-'}</td> <td>{subscription.details || '-'}</td>
</tr> </tr>
<tr> <tr>
<th>{$t('subscription.conditions')}</th> <th>{$t('subscriptions.conditions')}</th>
<td>{subscription.conditions || '-'}</td> <td>{subscription.conditions || '-'}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
{#if user.role_id == 8}
<div class="button-group"> <div class="button-group">
{#if hasPrivilige(user, PERMISSIONS.Super)}
<button <button
class="btn primary" class="btn primary"
on:click={() => openEditSubscriptionModal(subscription)} on:click={() => {
selected = subscription;
}}
> >
<i class="fas fa-edit"></i> <i class="fas fa-edit"></i>
{$t('edit')} {$t('edit')}
</button> </button>
{#if !users.some(/** @param{App.Locals['user']} user */ (user) => user.membership?.subscription_model?.id === subscription.id)} {/if}
{#if !members.some(/** @param{App.Locals['user']} user */ (user) => user.membership?.subscription?.id === subscription.id)}
<form <form
method="POST" method="POST"
action="?/subscriptionDelete" action="?/subscriptionDelete"
@@ -376,8 +556,115 @@
</form> </form>
{/if} {/if}
</div> </div>
</div>
</details>
{/each}
</div>
{:else if activeSection === 'cars'}
<div class="section-header">
<h2>{$t('cars')}</h2>
{#if hasPrivilige(user, PERMISSIONS.Super)}
<button
class="btn primary"
on:click={() => {
selected = defaultCar();
}}
>
<i class="fas fa-plus"></i>
{$t('add_new')}
</button>
{/if} {/if}
</div> </div>
<div class="accordion">
{#each cars as car}
<details class="accordion-item">
<summary class="accordion-header">
{car.model + ' (' + car.licence_plate + ')'}
</summary>
<div class="accordion-content">
<table class="table">
<tbody>
<tr>
<th>{$t('car.model')}</th>
<td>{car.brand + ' ' + car.model + ' (' + car.color + ')'}</td>
</tr>
<tr>
<th>{$t('price')}</th>
<td
>{car.price + '€'}{car.rate
? ' + ' + car.rate + '€/' + $t('month')
: ''}</td
>
</tr>
<tr>
<th>{$t('car.damages')}</th>
<td>{car.damages?.length || 0}</td>
</tr>
<tr>
<th>{$t('insurance')}</th>
<td
>{car.insurance
? car.insurance.company + '(' + car.insurance.reference + ')'
: '-'}</td
>
</tr>
<tr>
<th>{$t('car.end_date')}</th>
<td>{car.end_date || '-'}</td>
</tr>
</tbody>
</table>
<div class="button-group">
{#if hasPrivilige(user, PERMISSIONS.Update)}
<button
class="btn primary"
on:click={() => {
selected = car;
}}
>
<i class="fas fa-edit"></i>
{$t('edit')}
</button>
{/if}
{#if hasPrivilige(user, PERMISSIONS.Delete)}
<form
method="POST"
action="?/carDelete"
use:enhance={() => {
return async ({ result }) => {
if (result.type === 'success' || result.type === 'redirect') {
await applyAction(result);
} else {
document
.querySelector('.accordion-content')
?.scrollTo({ top: 0, behavior: 'smooth' });
await applyAction(result);
}
};
}}
on:submit|preventDefault={(/** @type {SubmitEvent} */ e) => {
if (
!confirm(
$t('dialog.car_deletion', {
values: {
name: car.brand + ' ' + car.model + ' (' + car.licence_plate + ')'
}
})
)
) {
e.preventDefault(); // Cancel form submission if user declines
}
}}
>
<input type="hidden" name="car[id]" value={car.id} />
<button class="btn danger" type="submit">
<i class="fas fa-trash"></i>
{$t('delete')}
</button>
</form>
{/if}
</div>
</div>
</details> </details>
{/each} {/each}
</div> </div>
@@ -411,27 +698,34 @@
</div> </div>
</div> </div>
{#if showUserModal} {#if selected && 'email' in selected}
// user
<Modal on:close={close}> <Modal on:close={close}>
<UserEditForm <UserEditForm
{form} {form}
user={selectedUser} editor={user}
user={selected}
{subscriptions} {subscriptions}
{licence_categories} {licence_categories}
on:cancel={close} on:cancel={close}
on:close={close} on:close={close}
/> />
</Modal> </Modal>
{:else if showSubscriptionModal} {:else if selected && 'monthly_fee' in selected}
//subscription
<Modal on:close={close}> <Modal on:close={close}>
<SubscriptionEditForm <SubscriptionEditForm
{form} {form}
{user} {user}
subscription={selectedSubscription} subscription={selected}
on:cancel={close} on:cancel={close}
on:close={close} on:close={close}
/> />
</Modal> </Modal>
{:else if selected && 'brand' in selected}
<Modal on:close={close}>
<CarEditForm {form} editor={user} {users} car={selected} on:cancel={close} on:close={close} />
</Modal>
{/if} {/if}
<style> <style>
@@ -445,6 +739,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
flex-wrap: wrap; /* Allows wrapping on small screens */
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.container { .container {

View File

@@ -0,0 +1,17 @@
<script>
import { page } from '$app/state';
import { t } from 'svelte-i18n';
let message = '';
if (page.url.search) {
message = page.url.search.split('=')[1].replaceAll('%20', ' ');
}
</script>
<div class="container">
<div class="content">
<h1 class="step-title title">{$t('email_sent')}</h1>
<h4 class="step-subtitle normal">
{$t(message)}
</h4>
</div>
</div>

View File

@@ -1,14 +1,15 @@
import { BASE_API_URI } from "$lib/utils/constants"; import { base } from '$app/paths';
import { formatError } from "$lib/utils/helpers"; import { BASE_API_URI } from '$lib/utils/constants';
import { fail, redirect } from "@sveltejs/kit"; import { formatError } from '$lib/utils/helpers';
import { fail, redirect } from '@sveltejs/kit';
/** @type {import('./$types').PageServerLoad} */ /** @type {import('./$types').PageServerLoad} */
export async function load({ locals }) { export async function load({ locals }) {
// redirect user if logged in // redirect user if logged in
console.log("loading login page"); console.log('loading login page');
if (locals.user) { if (locals.user) {
console.log("user is logged in"); console.log('user is logged in');
throw redirect(302, "/"); throw redirect(302, `${base}/auth/about/${locals.user.id}`);
} }
} }
@@ -22,26 +23,27 @@ export const actions = {
* @returns Error data or redirects user to the home page or the previous page * @returns Error data or redirects user to the home page or the previous page
*/ */
login: async ({ request, fetch, cookies }) => { login: async ({ request, fetch, cookies }) => {
console.log("login action called"); console.log('login action called');
const data = await request.formData(); const data = await request.formData();
const email = String(data.get("email")); const email = String(data.get('email'));
const password = String(data.get("password")); const password = String(data.get('password'));
const next = String(data.get("next")); const next = String(data.get('next'));
/** @type {RequestInit} */ /** @type {RequestInit} */
const requestInitOptions = { const requestInitOptions = {
method: "POST", method: 'POST',
credentials: "include", credentials: 'include',
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
email: email, email: email,
password: password, password: password
}), })
}; };
console.log('API call url:', `${BASE_API_URI}/users/login`);
const res = await fetch(`${BASE_API_URI}/users/login`, requestInitOptions); const res = await fetch(`${BASE_API_URI}/users/login`, requestInitOptions);
console.log("Login response status:", res.status); console.log('Login response status:', res.status);
console.log("Login response headers:", Object.fromEntries(res.headers)); console.log('Login response headers:', Object.fromEntries(res.headers));
if (!res.ok) { if (!res.ok) {
const errorData = await res.json(); const errorData = await res.json();
@@ -50,26 +52,26 @@ export const actions = {
} }
const responseBody = await res.json(); const responseBody = await res.json();
console.log("Login response body:", responseBody); console.log('Login response body:', responseBody);
// Extract the JWT from the response headers // Extract the JWT from the response headers
const setCookieHeader = res.headers.get("set-cookie"); const setCookieHeader = res.headers.get('set-cookie');
if (setCookieHeader) { if (setCookieHeader) {
const jwtMatch = setCookieHeader.match(/jwt=([^;]+)/); const jwtMatch = setCookieHeader.match(/jwt=([^;]+)/);
if (jwtMatch) { if (jwtMatch) {
const jwtValue = jwtMatch[1]; const jwtValue = jwtMatch[1];
// Set the cookie for the client // Set the cookie for the client
cookies.set("jwt", jwtValue, { cookies.set('jwt', jwtValue, {
path: "/", path: '/',
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === "production", // Secure in production secure: process.env.NODE_ENV === 'production', // Secure in production
sameSite: "lax", sameSite: 'lax',
maxAge: 5 * 24 * 60 * 60, // 5 days in seconds maxAge: 5 * 24 * 60 * 60 // 5 days in seconds
}); });
} }
} }
console.log("Redirecting to:", next || "/"); console.log('Redirecting to:', next || `${base}/auth/about/${responseBody.user_id}`);
throw redirect(303, next || "/"); throw redirect(303, next || `${base}/auth/about/${responseBody.user_id}`);
}, }
}; };

View File

@@ -1,5 +1,6 @@
<script> <script>
import { applyAction, enhance } from '$app/forms'; import { applyAction, enhance } from '$app/forms';
import { base } from '$app/paths';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { receive, send } from '$lib/utils/helpers'; import { receive, send } from '$lib/utils/helpers';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
@@ -36,7 +37,7 @@
{/if} {/if}
{#if message} {#if message}
<h4 class="step-subtitle">{message}</h4> <h4 class="step-subtitle">{$t(message)}</h4>
{/if} {/if}
<input type="hidden" name="next" value={$page.url.searchParams.get('next')} /> <input type="hidden" name="next" value={$page.url.searchParams.get('next')} />
@@ -53,7 +54,8 @@
name="password" name="password"
placeholder={$t('placeholder.password')} placeholder={$t('placeholder.password')}
/> />
<a href="/auth/password/request-change" class="forgot-password">{$t('forgot_password')}?</a> <a href={`${base}/auth/password/change`} class="forgot-password">{$t('forgot_password')}?</a
>
</div> </div>
</div> </div>
<div class="btn-container"> <div class="btn-container">

View File

@@ -1,3 +1,4 @@
import { base } from '$app/paths';
import { BASE_API_URI } from '$lib/utils/constants'; import { BASE_API_URI } from '$lib/utils/constants';
import { fail, redirect } from '@sveltejs/kit'; import { fail, redirect } from '@sveltejs/kit';
@@ -5,7 +6,7 @@ import { fail, redirect } from '@sveltejs/kit';
export async function load({ locals }) { export async function load({ locals }) {
// redirect user if not logged in // redirect user if not logged in
if (!locals.user) { if (!locals.user) {
throw redirect(302, `/auth/login?next=/`); throw redirect(302, `${base}/auth/login?next=${base}/`);
} }
} }
@@ -22,7 +23,7 @@ export const actions = {
} }
}; };
const res = await fetch(`${BASE_API_URI}/backend/logout/`, requestInitOptions); const res = await fetch(`${BASE_API_URI}/auth/logout/`, requestInitOptions);
if (!res.ok) { if (!res.ok) {
const response = await res.json(); const response = await res.json();
@@ -49,6 +50,6 @@ export const actions = {
}); });
} }
// redirect the user // redirect the user
throw redirect(302, '/auth/login'); throw redirect(302, `${base}/auth/login`);
} }
}; };

View File

@@ -0,0 +1,35 @@
import { base } from '$app/paths';
import { BASE_API_URI } from '$lib/utils/constants';
import { formatError } from '$lib/utils/helpers';
import { fail, redirect } from '@sveltejs/kit';
/** @type {import('./$types').Actions} */
export const actions = {
default: async ({ fetch, request }) => {
const formData = await request.formData();
const email = String(formData.get('email'));
/** @type {RequestInit} */
const requestInitOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email: email })
};
const res = await fetch(`${BASE_API_URI}/users/password/request-change/`, requestInitOptions);
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.errors);
return fail(400, { errors: errors });
}
const response = await res.json();
// redirect the user
console.log('redirecting to: ', `${base}/auth/confirming?message=${response.message}`);
throw redirect(302, `${base}/auth/confirming?message=${response.message}`);
}
};

View File

@@ -0,0 +1,53 @@
<script>
import { applyAction, enhance } from '$app/forms';
import SmallLoader from '$lib/components/SmallLoader.svelte';
import { receive, send } from '$lib/utils/helpers';
import { t } from 'svelte-i18n';
/** @type {import('./$types').ActionData} */
export let form;
let loading = false;
/** @type {import('./$types').SubmitFunction} */
const handleRequestChange = async () => {
loading = true;
return async ({ result }) => {
await applyAction(result);
loading = false;
};
};
</script>
<div class="container">
<form class="content" method="POST" use:enhance={handleRequestChange}>
<h1 class="step-title">{$t('forgot_password')}</h1>
{#if form?.errors}
{#each form?.errors as error (error.id)}
<h4
class="step-subtitle warning"
in:receive={{ key: error.id }}
out:send={{ key: error.id }}
>
{$t(error.key)}
</h4>
{/each}
{/if}
<div class="input-box">
<span class="label">{$t('user.email')}:</span>
<input
class="input"
type="email"
name="email"
id="email"
placeholder={$t('placeholder.email')}
required
/>
</div>
{#if loading}
<SmallLoader width={30} message={$t('loading.please_wait')} />
{:else}
<button class="button-dark" disabled={loading}>{$t('confirm')}</button>
{/if}
</form>
</div>

View File

@@ -0,0 +1,51 @@
import { base } from '$app/paths';
import { BASE_API_URI } from '$lib/utils/constants';
import { formatError } from '$lib/utils/helpers';
import { fail, redirect } from '@sveltejs/kit';
/** @type {import('./$types').Actions} */
export const actions = {
default: async ({ fetch, request }) => {
const formData = await request.formData();
const password = String(formData.get('user[password]')).trim();
const confirmPassword = String(formData.get('confirm_password')).trim();
let token = String(formData.get('token'));
const userID = String(formData.get('user_id'));
// Some validations
/** @type {string | Array<{field: string, key: string}> | Record<string, {key: string}>} */
const fieldsError = [];
if (password.length < 8) {
fieldsError.push({ field: 'user.user', key: 'validation.password' });
}
if (confirmPassword !== password) {
fieldsError.push({ field: 'user.user', key: 'validation.password_match' });
}
if (Object.keys(fieldsError).length > 0) {
return fail(400, { errors: formatError(fieldsError) });
}
/** @type {RequestInit} */
const requestInitOptions = {
method: 'PATCH',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ token: token, password: password })
};
const res = await fetch(`${BASE_API_URI}/users/password/change/${userID}/`, requestInitOptions);
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.errors);
return fail(400, { errors: errors });
}
const response = await res.json();
// redirect the user
console.log('redirecting to: ', `${base}/auth/login?message=${response.message}`);
throw redirect(302, `${base}/auth/login?message=${response.message}`);
}
};

View File

@@ -0,0 +1,84 @@
<script>
import { applyAction, enhance } from '$app/forms';
import { page } from '$app/state';
import { receive, send } from '$lib/utils/helpers';
import { t } from 'svelte-i18n';
import InputField from '$lib/components/InputField.svelte';
import { onMount } from 'svelte';
let password = '',
confirm_password = '';
/** @type{string | null} */
let token = null;
onMount(() => {
token = page.url.searchParams.get('token');
console.log(token);
if (!token) {
form ||= { errors: [] }; // Ensure form exists with an errors array
form.errors.push({
field: 'server.general',
key: 'server.error.no_auth_token',
id: Math.random() * 1000
});
}
});
/** @type {import('./$types').ActionData} */
export let form;
/** @type {import('./$types').SubmitFunction} */
const handleChange = async () => {
return async ({ result }) => {
await applyAction(result);
};
};
</script>
<div class="container">
<form class="content" method="POST" use:enhance={handleChange}>
<h1 class="step-title title">{$t('change_password')}</h1>
{#if form?.errors}
{#each form.errors as error (error.id)}
<h4
class="step-subtitle warning"
in:receive={{ key: error.id }}
out:send={{ key: error.id }}
>
{$t(error.key, {
values: {
message:
error.field
.split(' ')
.map((tag) => $t(tag))
.join(', ') || ''
}
})}
</h4>
{/each}
{/if}
<input type="hidden" name="user_id" value={page.params.id} />
<input type="hidden" name="token" value={token} />
<InputField
name="user[password]"
type="password"
label={$t('password')}
placeholder={$t('placeholder.password')}
bind:value={password}
otherPasswordValue={confirm_password}
/>
<InputField
name="confirm_password"
type="password"
label={$t('confirm_password')}
placeholder={$t('placeholder.password')}
bind:value={confirm_password}
otherPasswordValue={password}
/>
<button class="button-dark">{$t('change_password')} </button>
</form>
</div>

View File

@@ -1,4 +1,6 @@
import adapter from '@sveltejs/adapter-auto'; import adapter from '@sveltejs/adapter-node';
const isProduction = process.env.NODE_ENV === 'production';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */
const config = { const config = {
@@ -6,7 +8,10 @@ const config = {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter. // If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters. // See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter() adapter: adapter(),
paths: {
base: isProduction ? '/backend' : ''
}
} }
}; };

View File

@@ -18,18 +18,18 @@ func main() {
config.LoadConfig() config.LoadConfig()
err := database.Open(config.DB.Path, config.Recipients.AdminEmail) db, err := database.Open(config.DB.Path, config.Recipients.AdminEmail, config.Env == "development")
if err != nil { if err != nil {
logger.Error.Fatalf("Couldn't init database: %v", err) logger.Error.Fatalf("Couldn't init database: %v", err)
} }
defer func() { defer func() {
if err := database.Close(); err != nil { if err := database.Close(db); err != nil {
logger.Error.Fatalf("Failed to close database: %v", err) logger.Error.Fatalf("Failed to close database: %v", err)
} }
}() }()
go server.Run() go server.Run(db)
gracefulShutdown() gracefulShutdown()
} }

10
go-backend/compose.yml Normal file
View File

@@ -0,0 +1,10 @@
services:
app:
build: .
container_name: carsharingBackend
ports:
- "8080:8080"
volumes:
- ./go-backend/configs/config.json:/root/configs/config.json:ro
- ./go-backend/data/db.sqlite3:/root/data/db.sqlite3
- ./go-backend/templates:/root/templates:ro

View File

@@ -2,6 +2,7 @@
"site": { "site": {
"WebsiteTitle": "My Carsharing Site", "WebsiteTitle": "My Carsharing Site",
"BaseUrl": "https://domain.de", "BaseUrl": "https://domain.de",
"FrontendPath": "",
"AllowOrigins": "https://domain.de" "AllowOrigins": "https://domain.de"
}, },
"Environment": "dev", "Environment": "dev",
@@ -18,7 +19,7 @@
"MailPath": "templates/email", "MailPath": "templates/email",
"HTMLPath": "templates/html", "HTMLPath": "templates/html",
"StaticPath": "templates/css", "StaticPath": "templates/css",
"LogoURI": "/images/LOGO.png" "LogoURI": "/assets/LOGO.png"
}, },
"auth": { "auth": {
"APIKey": "" "APIKey": ""

View File

@@ -0,0 +1 @@
Database Folder according to the project structure and the template configuration

View File

@@ -19,12 +19,14 @@ require (
github.com/kelseyhightower/envconfig v1.4.0 github.com/kelseyhightower/envconfig v1.4.0
github.com/mocktools/go-smtp-mock/v2 v2.3.1 github.com/mocktools/go-smtp-mock/v2 v2.3.1
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
github.com/wagslane/go-password-validator v0.3.0
) )
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
) )
require ( require (

View File

@@ -91,6 +91,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I=
github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=

View File

@@ -8,6 +8,8 @@
package config package config
import ( import (
"crypto/rand"
"encoding/base64"
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
@@ -15,7 +17,6 @@ import (
"github.com/kelseyhightower/envconfig" "github.com/kelseyhightower/envconfig"
"GoMembership/internal/utils"
"GoMembership/pkg/logger" "GoMembership/pkg/logger"
) )
@@ -27,6 +28,7 @@ type SiteConfig struct {
AllowOrigins string `json:"AllowOrigins" envconfig:"ALLOW_ORIGINS"` AllowOrigins string `json:"AllowOrigins" envconfig:"ALLOW_ORIGINS"`
WebsiteTitle string `json:"WebsiteTitle" envconfig:"WEBSITE_TITLE"` WebsiteTitle string `json:"WebsiteTitle" envconfig:"WEBSITE_TITLE"`
BaseURL string `json:"BaseUrl" envconfig:"BASE_URL"` BaseURL string `json:"BaseUrl" envconfig:"BASE_URL"`
FrontendPath string `json:"FrontendPath" envconfig:"FRONTEND_PATH"`
} }
type AuthenticationConfig struct { type AuthenticationConfig struct {
JWTSecret string JWTSecret string
@@ -60,6 +62,16 @@ type SecurityConfig struct {
Burst int `json:"Burst" default:"60" envconfig:"BURST_LIMIT"` Burst int `json:"Burst" default:"60" envconfig:"BURST_LIMIT"`
} `json:"RateLimits"` } `json:"RateLimits"`
} }
type CompanyConfig struct {
Name string `json:"Name" envconfig:"COMPANY_NAME"`
Address string `json:"Address" envconfig:"COMPANY_ADDRESS"`
City string `json:"City" envconfig:"COMPANY_CITY"`
ZipCode string `json:"ZipCode" envconfig:"COMPANY_ZIPCODE"`
Country string `json:"Country" envconfig:"COMPANY_COUNTRY"`
SepaPrefix string `json:"SepaPrefix" envconfig:"COMPANY_SEPA_PREFIX"`
}
type Config struct { type Config struct {
Auth AuthenticationConfig `json:"auth"` Auth AuthenticationConfig `json:"auth"`
Site SiteConfig `json:"site"` Site SiteConfig `json:"site"`
@@ -70,6 +82,7 @@ type Config struct {
DB DatabaseConfig `json:"db"` DB DatabaseConfig `json:"db"`
SMTP SMTPConfig `json:"smtp"` SMTP SMTPConfig `json:"smtp"`
Security SecurityConfig `json:"security"` Security SecurityConfig `json:"security"`
Company CompanyConfig `json:"company"`
} }
var ( var (
@@ -83,7 +96,9 @@ var (
Recipients RecipientsConfig Recipients RecipientsConfig
Env string Env string
Security SecurityConfig Security SecurityConfig
Company CompanyConfig
) )
var environmentOptions map[string]bool = map[string]bool{ var environmentOptions map[string]bool = map[string]bool{
"development": true, "development": true,
"production": true, "production": true,
@@ -95,15 +110,15 @@ var environmentOptions map[string]bool = map[string]bool{
// It also generates JWT and CSRF secrets. Returns a Config pointer or an error if any step fails. // It also generates JWT and CSRF secrets. Returns a Config pointer or an error if any step fails.
func LoadConfig() { func LoadConfig() {
CFGPath = os.Getenv("CONFIG_FILE_PATH") CFGPath = os.Getenv("CONFIG_FILE_PATH")
logger.Info.Printf("Config file environment: %v", CFGPath)
readFile(&CFG) readFile(&CFG)
readEnv(&CFG) readEnv(&CFG)
csrfSecret, err := utils.GenerateRandomString(32) logger.Info.Printf("Config file environment: %v", CFGPath)
csrfSecret, err := generateRandomString(32)
if err != nil { if err != nil {
logger.Error.Fatalf("could not generate CSRF secret: %v", err) logger.Error.Fatalf("could not generate CSRF secret: %v", err)
} }
jwtSecret, err := utils.GenerateRandomString(32) jwtSecret, err := generateRandomString(32)
if err != nil { if err != nil {
logger.Error.Fatalf("could not generate JWT secret: %v", err) logger.Error.Fatalf("could not generate JWT secret: %v", err)
} }
@@ -122,6 +137,7 @@ func LoadConfig() {
Security = CFG.Security Security = CFG.Security
Env = CFG.Env Env = CFG.Env
Site = CFG.Site Site = CFG.Site
Company = CFG.Company
logger.Info.Printf("Config loaded: %#v", CFG) logger.Info.Printf("Config loaded: %#v", CFG)
} }
@@ -159,3 +175,12 @@ func readEnv(cfg *Config) {
logger.Error.Fatalf("could not decode env variables: %#v", err) logger.Error.Fatalf("could not decode env variables: %#v", err)
} }
} }
func generateRandomString(length int) (string, error) {
bytes := make([]byte, length)
_, err := rand.Read(bytes)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes), nil
}

View File

@@ -0,0 +1,109 @@
package constants
const (
UnverifiedStatus = iota + 1
DisabledStatus
VerifiedStatus
ActiveStatus
PassiveStatus
DelayedPaymentStatus
SettledPaymentStatus
AwaitingPaymentStatus
MailVerificationSubject = "Nur noch ein kleiner Schritt!"
MailChangePasswordSubject = "Passwort Änderung angefordert"
MailGrantBackendAccessSubject = "Dein Dörpsmobil Hasloh e.V. Zugang"
MailRegistrationSubject = "Neues Mitglied hat sich registriert"
MailWelcomeSubject = "Willkommen beim Dörpsmobil Hasloh e.V."
MailContactSubject = "Jemand hat das Kontaktformular gefunden"
SupporterSubscriptionName = "Keins"
)
var Licences = struct {
AM string
A1 string
A2 string
A string
B string
C1 string
C string
D1 string
D string
BE string
C1E string
CE string
D1E string
DE string
L string
T string
}{
AM: "AM",
A1: "A1",
A2: "A2",
A: "A",
B: "B",
C1: "C1",
C: "C",
D1: "D1",
D: "D",
BE: "BE",
C1E: "C1E",
CE: "CE",
D1E: "D1E",
DE: "DE",
L: "L",
T: "T",
}
var VerificationTypes = struct {
Email string
Password string
}{
Email: "email",
Password: "password",
}
var Priviliges = struct {
View int8
Create int8
Update int8
Delete int8
AccessControl int8
}{
View: 2,
Update: 4,
Create: 4,
Delete: 4,
AccessControl: 8,
}
var Roles = struct {
Opponent int8
Supporter int8
Member int8
Viewer int8
Editor int8
Admin int8
}{
Opponent: -5,
Supporter: 0,
Member: 1,
Viewer: 2,
Editor: 4,
Admin: 8,
}
var MemberUpdateFields = map[string]bool{
"Email": true,
"Phone": true,
"Company": true,
"Address": true,
"ZipCode": true,
"City": true,
"Licence.Categories": true,
"BankAccount.Bank": true,
"BankAccount.AccountHolderName": true,
"BankAccount.IBAN": true,
"BankAccount.BIC": true,
}
// "Password": true,

View File

@@ -0,0 +1,116 @@
package controllers
import (
"GoMembership/internal/constants"
"GoMembership/internal/models"
"GoMembership/internal/services"
"GoMembership/internal/utils"
"GoMembership/pkg/errors"
"GoMembership/pkg/logger"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
)
type CarController struct {
S services.CarServiceInterface
UserService services.UserServiceInterface
}
func (cr *CarController) Create(c *gin.Context) {
requestUser, err := cr.UserService.FromContext(c)
if err != nil {
utils.RespondWithError(c, err, "Error extracting user from context in Create car handler", http.StatusBadRequest, errors.Responses.Fields.User, errors.Responses.Keys.NoAuthToken)
return
}
if !requestUser.HasPrivilege(constants.Priviliges.Create) {
utils.RespondWithError(c, errors.ErrNotAuthorized, fmt.Sprintf("Not allowed to create a car. RoleID(%v)<Privilige(%v)", requestUser.RoleID, constants.Priviliges.Create), http.StatusUnauthorized, errors.Responses.Fields.User, errors.Responses.Keys.Unauthorized)
return
}
var newCar models.Car
if err := c.ShouldBindJSON(&newCar); err != nil {
utils.HandleValidationError(c, err)
return
}
car, err := cr.S.Create(&newCar)
if err != nil {
utils.RespondWithError(c, err, "Error creating car", http.StatusInternalServerError, errors.Responses.Fields.Car, errors.Responses.Keys.InternalServerError)
return
}
c.JSON(http.StatusCreated, car)
}
func (cr *CarController) Update(c *gin.Context) {
requestUser, err := cr.UserService.FromContext(c)
if err != nil {
utils.RespondWithError(c, err, "Error extracting user from context in Update car handler", http.StatusBadRequest, errors.Responses.Fields.User, errors.Responses.Keys.NoAuthToken)
return
}
if !requestUser.HasPrivilege(constants.Priviliges.Update) {
utils.RespondWithError(c, errors.ErrNotAuthorized, fmt.Sprintf("Not allowed to update a car. RoleID(%v)<Privilige(%v)", requestUser.RoleID, constants.Priviliges.Update), http.StatusUnauthorized, errors.Responses.Fields.User, errors.Responses.Keys.Unauthorized)
return
}
var car models.Car
if err := c.ShouldBindJSON(&car); err != nil {
utils.HandleValidationError(c, err)
return
}
logger.Error.Printf("updating car: %v", car)
updatedCar, err := cr.S.Update(&car)
if err != nil {
utils.RespondWithError(c, err, "Error updating car", http.StatusInternalServerError, errors.Responses.Fields.Car, errors.Responses.Keys.InternalServerError)
return
}
c.JSON(http.StatusOK, updatedCar)
}
func (cr *CarController) GetAll(c *gin.Context) {
requestUser, err := cr.UserService.FromContext(c)
if err != nil {
utils.RespondWithError(c, err, "Error extracting user from context in GetAll car handler", http.StatusBadRequest, errors.Responses.Fields.User, errors.Responses.Keys.NoAuthToken)
return
}
if !requestUser.HasPrivilege(constants.Priviliges.View) {
utils.RespondWithError(c, errors.ErrNotAuthorized, fmt.Sprintf("Not allowed to access car data. RoleID(%v)<Privilige(%v)", requestUser.RoleID, constants.Priviliges.Delete), http.StatusUnauthorized, errors.Responses.Fields.User, errors.Responses.Keys.Unauthorized)
return
}
cars, err := cr.S.GetAll()
if err != nil {
utils.RespondWithError(c, err, "Error getting cars", http.StatusInternalServerError, errors.Responses.Fields.Car, errors.Responses.Keys.InternalServerError)
return
}
c.JSON(http.StatusOK, gin.H{
"cars": cars,
})
}
func (cr *CarController) Delete(c *gin.Context) {
type input struct {
ID uint `json:"id" binding:"required,numeric"`
}
var deleteData input
requestUser, err := cr.UserService.FromContext(c)
if err != nil {
utils.RespondWithError(c, err, "Error extracting user from context in Delete car handler", http.StatusBadRequest, errors.Responses.Fields.User, errors.Responses.Keys.NoAuthToken)
return
}
if !requestUser.HasPrivilege(constants.Priviliges.Delete) {
utils.RespondWithError(c, errors.ErrNotAuthorized, fmt.Sprintf("Not allowed to delete a car. RoleID(%v)<Privilige(%v)", requestUser.RoleID, constants.Priviliges.Delete), http.StatusUnauthorized, errors.Responses.Fields.User, errors.Responses.Keys.Unauthorized)
return
}
if err := c.ShouldBindJSON(&deleteData); err != nil {
utils.HandleValidationError(c, err)
return
}
err = cr.S.Delete(&deleteData.ID)
if err != nil {
utils.RespondWithError(c, err, "Error deleting car", http.StatusInternalServerError, errors.Responses.Fields.Car, errors.Responses.Keys.InternalServerError)
return
}
c.JSON(http.StatusOK, "Car deleted")
}

View File

@@ -17,6 +17,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"GoMembership/internal/config" "GoMembership/internal/config"
"GoMembership/internal/constants"
"GoMembership/internal/database" "GoMembership/internal/database"
"GoMembership/internal/models" "GoMembership/internal/models"
"GoMembership/internal/repositories" "GoMembership/internal/repositories"
@@ -47,9 +48,11 @@ var (
Uc *UserController Uc *UserController
Mc *MembershipController Mc *MembershipController
Cc *ContactController Cc *ContactController
AdminCookie *http.Cookie
MemberCookie *http.Cookie
) )
func TestSuite(t *testing.T) { func TestMain(t *testing.T) {
_ = deleteTestDB("test.db") _ = deleteTestDB("test.db")
cwd, err := os.Getwd() cwd, err := os.Getwd()
@@ -84,7 +87,8 @@ func TestSuite(t *testing.T) {
log.Fatalf("Error setting environment variable: %v", err) log.Fatalf("Error setting environment variable: %v", err)
} }
config.LoadConfig() config.LoadConfig()
if err := database.Open("test.db", config.Recipients.AdminEmail); err != nil { db, err := database.Open("test.db", config.Recipients.AdminEmail, true)
if err != nil {
log.Fatalf("Failed to create DB: %#v", err) log.Fatalf("Failed to create DB: %#v", err)
} }
utils.SMTPStart(Host, Port) utils.SMTPStart(Host, Port)
@@ -96,17 +100,16 @@ func TestSuite(t *testing.T) {
bankAccountService := &services.BankAccountService{Repo: bankAccountRepo} bankAccountService := &services.BankAccountService{Repo: bankAccountRepo}
var membershipRepo repositories.MembershipRepositoryInterface = &repositories.MembershipRepository{} var membershipRepo repositories.MembershipRepositoryInterface = &repositories.MembershipRepository{}
var subscriptionRepo repositories.SubscriptionModelsRepositoryInterface = &repositories.SubscriptionModelsRepository{} var subscriptionRepo repositories.SubscriptionsRepositoryInterface = &repositories.SubscriptionsRepository{}
membershipService := &services.MembershipService{Repo: membershipRepo, SubscriptionRepo: subscriptionRepo} membershipService := &services.MembershipService{Repo: membershipRepo, SubscriptionRepo: subscriptionRepo}
var licenceRepo repositories.LicenceInterface = &repositories.LicenceRepository{} var licenceRepo repositories.LicenceInterface = &repositories.LicenceRepository{}
var userRepo repositories.UserRepositoryInterface = &repositories.UserRepository{} userService := &services.UserService{DB: db, Licences: licenceRepo}
userService := &services.UserService{Repo: userRepo, Licences: licenceRepo}
licenceService := &services.LicenceService{Repo: licenceRepo} licenceService := &services.LicenceService{Repo: licenceRepo}
Uc = &UserController{Service: userService, LicenceService: licenceService, EmailService: emailService, ConsentService: consentService, BankAccountService: bankAccountService, MembershipService: membershipService} Uc = &UserController{Service: userService, LicenceService: licenceService, EmailService: emailService, ConsentService: consentService, BankAccountService: bankAccountService, MembershipService: membershipService}
Mc = &MembershipController{UserController: &MockUserController{}, Service: *membershipService} Mc = &MembershipController{UserService: userService, Service: membershipService}
Cc = &ContactController{EmailService: emailService} Cc = &ContactController{EmailService: emailService}
if err := initSubscriptionPlans(); err != nil { if err := initSubscriptionPlans(); err != nil {
@@ -116,11 +119,36 @@ func TestSuite(t *testing.T) {
if err := initLicenceCategories(); err != nil { if err := initLicenceCategories(); err != nil {
log.Fatalf("Failed to init Categories: %v", err) log.Fatalf("Failed to init Categories: %v", err)
} }
validation.SetupValidators() password := "securepassword"
admin := models.User{
FirstName: "Ad",
LastName: "min",
Email: "admin@example.com",
DateOfBirth: time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC),
Company: "SampleCorp",
Phone: "+123456789",
Address: "123 Main Street",
ZipCode: "12345",
City: "SampleCity",
Status: constants.ActiveStatus,
Password: password,
Notes: "",
RoleID: constants.Roles.Admin,
Consents: nil,
Verifications: nil,
Membership: nil,
BankAccount: nil,
Licence: &models.Licence{
Status: constants.UnverifiedStatus,
}}
admin.Create(db)
validation.SetupValidators(db)
t.Run("userController", func(t *testing.T) { t.Run("userController", func(t *testing.T) {
testUserController(t) testUserController(t)
}) })
t.Run("Password_Controller", func(t *testing.T) {
})
t.Run("SQL_Injection", func(t *testing.T) { t.Run("SQL_Injection", func(t *testing.T) {
testSQLInjectionAttempt(t) testSQLInjectionAttempt(t)
}) })
@@ -136,7 +164,6 @@ func TestSuite(t *testing.T) {
t.Run("XSSAttempt", func(t *testing.T) { t.Run("XSSAttempt", func(t *testing.T) {
testXSSAttempt(t) testXSSAttempt(t)
}) })
if err := utils.SMTPStop(); err != nil { if err := utils.SMTPStop(); err != nil {
log.Fatalf("Failed to stop SMTP Mockup Server: %#v", err) log.Fatalf("Failed to stop SMTP Mockup Server: %#v", err)
} }
@@ -176,7 +203,7 @@ func initLicenceCategories() error {
} }
func initSubscriptionPlans() error { func initSubscriptionPlans() error {
subscriptions := []models.SubscriptionModel{ subscriptions := []models.Subscription{
{ {
Name: "Basic", Name: "Basic",
Details: "Test Plan", Details: "Test Plan",
@@ -256,15 +283,33 @@ func getBaseUser() models.User {
ZipCode: "25474", ZipCode: "25474",
City: "Hasloh", City: "Hasloh",
Phone: "01738484993", Phone: "01738484993",
BankAccount: models.BankAccount{IBAN: "DE89370400440532013000"}, BankAccount: &models.BankAccount{IBAN: "DE89370400440532013000"},
Membership: models.Membership{SubscriptionModel: models.SubscriptionModel{Name: "Basic"}}, Membership: &models.Membership{Subscription: models.Subscription{Name: "Basic"}},
Licence: nil, Licence: nil,
ProfilePicture: "", Password: "passw@#$#%$!-ord123",
Password: "password123",
Company: "", Company: "",
RoleID: 1,
} }
} }
func getBaseSupporter() models.User {
return models.User{
DateOfBirth: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC),
FirstName: "John",
LastName: "Rich",
Email: "john.supporter@example.com",
Address: "Pablo Escobar Str. 4",
ZipCode: "25474",
City: "Hasloh",
Phone: "01738484993",
BankAccount: &models.BankAccount{IBAN: "DE89370400440532013000"},
Membership: &models.Membership{Subscription: models.Subscription{Name: "Basic"}},
Licence: nil,
Password: "passw@#$#%$!-ord123",
Company: "",
RoleID: 0,
}
}
func deleteTestDB(dbPath string) error { func deleteTestDB(dbPath string) error {
err := os.Remove(dbPath) err := os.Remove(dbPath)
if err != nil { if err != nil {

View File

@@ -3,13 +3,14 @@ package controllers
import ( import (
"GoMembership/internal/services" "GoMembership/internal/services"
"GoMembership/internal/utils" "GoMembership/internal/utils"
"GoMembership/pkg/errors"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type LicenceController struct { type LicenceController struct {
Service services.LicenceService Service services.LicenceServiceInterface
} }
func (lc *LicenceController) GetAllCategories(c *gin.Context) { func (lc *LicenceController) GetAllCategories(c *gin.Context) {
@@ -17,7 +18,7 @@ func (lc *LicenceController) GetAllCategories(c *gin.Context) {
categories, err := lc.Service.GetAllCategories() categories, err := lc.Service.GetAllCategories()
if err != nil { if err != nil {
utils.RespondWithError(c, err, "Error retrieving licence categories", http.StatusInternalServerError, "general", "server.error.internal_server_error") utils.RespondWithError(c, err, "Error retrieving licence categories", http.StatusInternalServerError, errors.Responses.Fields.Licences, errors.Responses.Keys.InternalServerError)
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{

View File

@@ -0,0 +1,94 @@
package controllers
import (
"errors"
"net/http"
"net/http/httptest"
"testing"
"GoMembership/internal/models"
"GoMembership/internal/services"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// Mock Repository
type MockLicenceRepo struct {
mock.Mock
}
func (m *MockLicenceRepo) GetAllCategories() ([]models.Category, error) {
args := m.Called()
categories, _ := args.Get(0).([]models.Category) // Safe type assertion
return categories, args.Error(1)
}
func (r *MockLicenceRepo) FindCategoriesByIDs(ids []uint) ([]models.Category, error) {
return []models.Category{}, nil
}
func (r *MockLicenceRepo) FindCategoryByName(categoryName string) (models.Category, error) {
return models.Category{}, nil
}
func TestGetAllCategories_Success(t *testing.T) {
gin.SetMode(gin.TestMode)
// Mock repository
mockRepo := new(MockLicenceRepo)
expectedCategories := []models.Category{
{ID: 1, Name: "Category A"},
{ID: 2, Name: "Category B"},
}
mockRepo.On("GetAllCategories").Return(expectedCategories, nil)
// Create LicenceService with mocked repository
service := &services.LicenceService{Repo: mockRepo}
// Create controller with service
lc := &LicenceController{Service: service}
// Setup router and request
router := gin.Default()
router.GET("/licence/categories", lc.GetAllCategories)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/licence/categories", nil)
router.ServeHTTP(w, req)
// Assertions
assert.Equal(t, http.StatusOK, w.Code)
assert.JSONEq(t, `{"licence_categories":[{"id":1,"category":"Category A"},{"id":2,"category":"Category B"}]}`, w.Body.String())
mockRepo.AssertExpectations(t)
}
func TestGetAllCategories_Error(t *testing.T) {
gin.SetMode(gin.TestMode)
// Mock repository
mockRepo := new(MockLicenceRepo)
mockRepo.On("GetAllCategories").Return(nil, errors.New("database error"))
// Create LicenceService with mocked repository
service := &services.LicenceService{Repo: mockRepo}
// Create controller with service
lc := &LicenceController{Service: service}
// Setup router and request
router := gin.Default()
router.GET("/licence/categories", lc.GetAllCategories)
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/licence/categories", nil)
router.ServeHTTP(w, req)
// Assertions
assert.Equal(t, http.StatusInternalServerError, w.Code)
assert.Contains(t, w.Body.String(), "server.error.internal_server_error")
mockRepo.AssertExpectations(t)
}

View File

@@ -0,0 +1,132 @@
package controllers
import (
"GoMembership/internal/constants"
"GoMembership/internal/models"
"GoMembership/internal/services"
"GoMembership/internal/utils"
"strings"
"net/http"
"github.com/gin-gonic/gin"
"GoMembership/pkg/errors"
"GoMembership/pkg/logger"
)
type MembershipController struct {
Service services.MembershipServiceInterface
UserService services.UserServiceInterface
}
func (mc *MembershipController) RegisterSubscription(c *gin.Context) {
requestUser, err := mc.UserService.FromContext(c)
if err != nil {
utils.RespondWithError(c, err, "Error extracting user from context in subscription registrationHandler", http.StatusBadRequest, errors.Responses.Fields.User, errors.Responses.Keys.NoAuthToken)
return
}
if !requestUser.HasPrivilege(constants.Priviliges.Create) {
utils.RespondWithError(c, errors.ErrNotAuthorized, "Not allowed to register subscription", http.StatusUnauthorized, errors.Responses.Fields.User, errors.Responses.Keys.Unauthorized)
return
}
var subscription models.Subscription
if err := c.ShouldBindJSON(&subscription); err != nil {
utils.HandleValidationError(c, err)
return
}
// Register Subscription
id, err := mc.Service.RegisterSubscription(&subscription)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
utils.RespondWithError(c, err, "Subscription already exists", http.StatusConflict, errors.Responses.Fields.Subscription, errors.Responses.Keys.Duplicate)
} else {
utils.RespondWithError(c, err, "Couldn't register Membershipmodel", http.StatusInternalServerError, errors.Responses.Fields.Subscription, errors.Responses.Keys.InternalServerError)
}
return
}
logger.Info.Printf("registering subscription: %+v", subscription)
c.JSON(http.StatusCreated, gin.H{
"status": "success",
"id": id,
})
}
func (mc *MembershipController) UpdateHandler(c *gin.Context) {
requestUser, err := mc.UserService.FromContext(c)
if err != nil {
utils.RespondWithError(c, err, "Error extracting user from context in subscription Updatehandler", http.StatusBadRequest, errors.Responses.Fields.User, errors.Responses.Keys.NoAuthToken)
return
}
if !requestUser.HasPrivilege(constants.Priviliges.Update) {
utils.RespondWithError(c, errors.ErrNotAuthorized, "Not allowed to update subscription", http.StatusUnauthorized, errors.Responses.Fields.User, errors.Responses.Keys.Unauthorized)
return
}
var subscription models.Subscription
if err := c.ShouldBindJSON(&subscription); err != nil {
utils.HandleValidationError(c, err)
return
}
// update Subscription
logger.Info.Printf("Updating subscription %v", subscription.Name)
id, err := mc.Service.UpdateSubscription(&subscription)
if err != nil {
utils.HandleSubscriptionUpdateError(c, err)
return
}
c.JSON(http.StatusAccepted, gin.H{
"status": "success",
"id": id,
})
}
func (mc *MembershipController) DeleteSubscription(c *gin.Context) {
type deleteData struct {
ID uint `json:"id" binding:"required,numeric,safe_content"`
Name string `json:"name" binding:"required,safe_content"`
}
var subscription deleteData
requestUser, err := mc.UserService.FromContext(c)
if err != nil {
utils.RespondWithError(c, err, "Error extracting user from context in subscription deleteSubscription", http.StatusBadRequest, errors.Responses.Fields.User, errors.Responses.Keys.NoAuthToken)
return
}
if !requestUser.HasPrivilege(constants.Priviliges.Delete) {
utils.RespondWithError(c, errors.ErrNotAuthorized, "Not allowed to update subscription", http.StatusUnauthorized, errors.Responses.Fields.User, errors.Responses.Keys.Unauthorized)
return
}
if err := c.ShouldBindJSON(&subscription); err != nil {
utils.HandleValidationError(c, err)
return
}
if err := mc.Service.DeleteSubscription(&subscription.ID, &subscription.Name); err != nil {
utils.HandleSubscriptionDeleteError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "Subscription deleted successfully"})
}
func (mc *MembershipController) GetSubscriptions(c *gin.Context) {
subscriptions, err := mc.Service.GetSubscriptions(nil)
if err != nil {
utils.RespondWithError(c, err, "Error retrieving subscriptions", http.StatusInternalServerError, errors.Responses.Fields.Subscription, errors.Responses.Keys.InternalServerError)
return
}
c.JSON(http.StatusOK, gin.H{
"subscriptions": subscriptions,
})
}

View File

@@ -6,7 +6,6 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "testing"
"GoMembership/internal/constants"
"GoMembership/internal/database" "GoMembership/internal/database"
"GoMembership/internal/models" "GoMembership/internal/models"
"GoMembership/pkg/logger" "GoMembership/pkg/logger"
@@ -15,6 +14,7 @@ import (
) )
type RegisterSubscriptionTest struct { type RegisterSubscriptionTest struct {
SetupCookie func(r *http.Request)
WantDBData map[string]interface{} WantDBData map[string]interface{}
Input string Input string
Name string Name string
@@ -23,6 +23,7 @@ type RegisterSubscriptionTest struct {
} }
type UpdateSubscriptionTest struct { type UpdateSubscriptionTest struct {
SetupCookie func(r *http.Request)
WantDBData map[string]interface{} WantDBData map[string]interface{}
Input string Input string
Name string Name string
@@ -31,6 +32,7 @@ type UpdateSubscriptionTest struct {
} }
type DeleteSubscriptionTest struct { type DeleteSubscriptionTest struct {
SetupCookie func(r *http.Request)
WantDBData map[string]interface{} WantDBData map[string]interface{}
Input string Input string
Name string Name string
@@ -38,29 +40,8 @@ type DeleteSubscriptionTest struct {
Assert bool Assert bool
} }
type MockUserController struct {
UserController // Embed the UserController
}
func (m *MockUserController) ExtractUserFromContext(c *gin.Context) (*models.User, error) {
return &models.User{
ID: 1,
FirstName: "Admin",
LastName: "User",
Email: "admin@test.com",
RoleID: constants.Roles.Admin,
}, nil
}
func setupMockAuth() {
// Create and assign the mock controller
mockController := &MockUserController{}
Mc.UserController = mockController
}
func testMembershipController(t *testing.T) { func testMembershipController(t *testing.T) {
setupMockAuth()
tests := getSubscriptionRegistrationData() tests := getSubscriptionRegistrationData()
for _, tt := range tests { for _, tt := range tests {
logger.Error.Print("==============================================================") logger.Error.Print("==============================================================")
@@ -101,6 +82,7 @@ func (rt *RegisterSubscriptionTest) SetupContext() (*gin.Context, *httptest.Resp
} }
func (rt *RegisterSubscriptionTest) RunHandler(c *gin.Context, router *gin.Engine) { func (rt *RegisterSubscriptionTest) RunHandler(c *gin.Context, router *gin.Engine) {
rt.SetupCookie(c.Request)
Mc.RegisterSubscription(c) Mc.RegisterSubscription(c)
} }
@@ -131,6 +113,7 @@ func (ut *UpdateSubscriptionTest) SetupContext() (*gin.Context, *httptest.Respon
} }
func (ut *UpdateSubscriptionTest) RunHandler(c *gin.Context, router *gin.Engine) { func (ut *UpdateSubscriptionTest) RunHandler(c *gin.Context, router *gin.Engine) {
ut.SetupCookie(c.Request)
Mc.UpdateHandler(c) Mc.UpdateHandler(c)
} }
@@ -150,6 +133,7 @@ func (dt *DeleteSubscriptionTest) SetupContext() (*gin.Context, *httptest.Respon
} }
func (dt *DeleteSubscriptionTest) RunHandler(c *gin.Context, router *gin.Engine) { func (dt *DeleteSubscriptionTest) RunHandler(c *gin.Context, router *gin.Engine) {
dt.SetupCookie(c.Request)
Mc.DeleteSubscription(c) Mc.DeleteSubscription(c)
} }
@@ -164,18 +148,16 @@ func (dt *DeleteSubscriptionTest) ValidateResult() error {
return validateSubscription(dt.Assert, dt.WantDBData) return validateSubscription(dt.Assert, dt.WantDBData)
} }
func getBaseSubscription() MembershipData { func getBaseSubscription() models.Subscription {
return MembershipData{ return models.Subscription{
// APIKey: config.Auth.APIKEY,
Subscription: models.SubscriptionModel{
Name: "Premium", Name: "Premium",
Details: "A subscription detail", Details: "A subscription detail",
MonthlyFee: 12.0, MonthlyFee: 12.0,
HourlyRate: 14.0, HourlyRate: 14.0,
},
} }
} }
func customizeSubscription(customize func(MembershipData) MembershipData) MembershipData {
func customizeSubscription(customize func(models.Subscription) models.Subscription) models.Subscription {
subscription := getBaseSubscription() subscription := getBaseSubscription()
return customize(subscription) return customize(subscription)
} }
@@ -183,61 +165,95 @@ func customizeSubscription(customize func(MembershipData) MembershipData) Member
func getSubscriptionRegistrationData() []RegisterSubscriptionTest { func getSubscriptionRegistrationData() []RegisterSubscriptionTest {
return []RegisterSubscriptionTest{ return []RegisterSubscriptionTest{
{ {
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Missing details should fail", Name: "Missing details should fail",
WantResponse: http.StatusBadRequest, WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": "Just a Subscription"}, WantDBData: map[string]interface{}{"name": "Just a Subscription"},
Assert: false, Assert: false,
Input: GenerateInputJSON( Input: GenerateInputJSON(
customizeSubscription(func(subscription MembershipData) MembershipData { customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Subscription.Details = "" subscription.Details = ""
return subscription return subscription
})), })),
}, },
{ {
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Missing model name should fail", Name: "Missing model name should fail",
WantResponse: http.StatusBadRequest, WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": ""}, WantDBData: map[string]interface{}{"name": ""},
Assert: false, Assert: false,
Input: GenerateInputJSON( Input: GenerateInputJSON(
customizeSubscription(func(subscription MembershipData) MembershipData { customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Subscription.Name = "" subscription.Name = ""
return subscription return subscription
})), })),
}, },
{ {
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Negative monthly fee should fail", Name: "Negative monthly fee should fail",
WantResponse: http.StatusBadRequest, WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": "Premium"}, WantDBData: map[string]interface{}{"name": "Premium"},
Assert: false, Assert: false,
Input: GenerateInputJSON(customizeSubscription(func(sub MembershipData) MembershipData { Input: GenerateInputJSON(customizeSubscription(func(sub models.Subscription) models.Subscription {
sub.Subscription.MonthlyFee = -10.0 sub.MonthlyFee = -10.0
return sub return sub
})), })),
}, },
{ {
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Negative hourly rate should fail", Name: "Negative hourly rate should fail",
WantResponse: http.StatusBadRequest, WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": "Premium"}, WantDBData: map[string]interface{}{"name": "Premium"},
Assert: false, Assert: false,
Input: GenerateInputJSON(customizeSubscription(func(sub MembershipData) MembershipData { Input: GenerateInputJSON(customizeSubscription(func(sub models.Subscription) models.Subscription {
sub.Subscription.HourlyRate = -1.0 sub.HourlyRate = -1.0
return sub return sub
})), })),
}, },
{ {
SetupCookie: func(req *http.Request) {
req.AddCookie(MemberCookie)
},
Name: "correct entry but not authorized",
WantResponse: http.StatusUnauthorized,
WantDBData: map[string]interface{}{"name": "Premium"},
Assert: false,
Input: GenerateInputJSON(
customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Conditions = "Some Condition"
subscription.IncludedPerYear = 0
subscription.IncludedPerMonth = 1
return subscription
})),
},
{
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "correct entry should pass", Name: "correct entry should pass",
WantResponse: http.StatusCreated, WantResponse: http.StatusCreated,
WantDBData: map[string]interface{}{"name": "Premium"}, WantDBData: map[string]interface{}{"name": "Premium"},
Assert: true, Assert: true,
Input: GenerateInputJSON( Input: GenerateInputJSON(
customizeSubscription(func(subscription MembershipData) MembershipData { customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Subscription.Conditions = "Some Condition" subscription.Conditions = "Some Condition"
subscription.Subscription.IncludedPerYear = 0 subscription.IncludedPerYear = 0
subscription.Subscription.IncludedPerMonth = 1 subscription.IncludedPerMonth = 1
return subscription return subscription
})), })),
}, },
{ {
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Duplicate subscription name should fail", Name: "Duplicate subscription name should fail",
WantResponse: http.StatusConflict, WantResponse: http.StatusConflict,
WantDBData: map[string]interface{}{"name": "Premium"}, WantDBData: map[string]interface{}{"name": "Premium"},
@@ -250,82 +266,120 @@ func getSubscriptionRegistrationData() []RegisterSubscriptionTest {
func getSubscriptionUpdateData() []UpdateSubscriptionTest { func getSubscriptionUpdateData() []UpdateSubscriptionTest {
return []UpdateSubscriptionTest{ return []UpdateSubscriptionTest{
{ {
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Modified Monthly Fee, should fail", Name: "Modified Monthly Fee, should fail",
WantResponse: http.StatusNotAcceptable, WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": "Premium", "monthly_fee": "12"}, WantDBData: map[string]interface{}{"name": "Premium", "monthly_fee": "12"},
Assert: true, Assert: true,
Input: GenerateInputJSON( Input: GenerateInputJSON(
customizeSubscription(func(subscription MembershipData) MembershipData { customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Subscription.MonthlyFee = 123.0 subscription.MonthlyFee = 123.0
return subscription return subscription
})), })),
}, },
{ {
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Missing ID, should fail", Name: "Missing ID, should fail",
WantResponse: http.StatusNotAcceptable, WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": "Premium"}, WantDBData: map[string]interface{}{"name": "Premium"},
Assert: true, Assert: true,
Input: GenerateInputJSON( Input: GenerateInputJSON(
customizeSubscription(func(subscription MembershipData) MembershipData { customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Subscription.ID = 0 subscription.ID = 0
return subscription return subscription
})), })),
}, },
{ {
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Modified Hourly Rate, should fail", Name: "Modified Hourly Rate, should fail",
WantResponse: http.StatusNotAcceptable, WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": "Premium", "hourly_rate": "14"}, WantDBData: map[string]interface{}{"name": "Premium", "hourly_rate": "14"},
Assert: true, Assert: true,
Input: GenerateInputJSON( Input: GenerateInputJSON(
customizeSubscription(func(subscription MembershipData) MembershipData { customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Subscription.HourlyRate = 3254.0 subscription.HourlyRate = 3254.0
return subscription return subscription
})), })),
}, },
{ {
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "IncludedPerYear changed, should fail", Name: "IncludedPerYear changed, should fail",
WantResponse: http.StatusNotAcceptable, WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": "Premium", "included_per_year": "0"}, WantDBData: map[string]interface{}{"name": "Premium", "included_per_year": "0"},
Assert: true, Assert: true,
Input: GenerateInputJSON( Input: GenerateInputJSON(
customizeSubscription(func(subscription MembershipData) MembershipData { customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Subscription.IncludedPerYear = 9873.0 subscription.IncludedPerYear = 9873.0
return subscription return subscription
})), })),
}, },
{ {
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "IncludedPerMonth changed, should fail", Name: "IncludedPerMonth changed, should fail",
WantResponse: http.StatusNotAcceptable, WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": "Premium", "included_per_month": "1"}, WantDBData: map[string]interface{}{"name": "Premium", "included_per_month": "1"},
Assert: true, Assert: true,
Input: GenerateInputJSON( Input: GenerateInputJSON(
customizeSubscription(func(subscription MembershipData) MembershipData { customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Subscription.IncludedPerMonth = 23415.0 subscription.IncludedPerMonth = 23415.0
return subscription return subscription
})), })),
}, },
{ {
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Update non-existent subscription should fail", Name: "Update non-existent subscription should fail",
WantResponse: http.StatusNotAcceptable, WantResponse: http.StatusNotFound,
WantDBData: map[string]interface{}{"name": "NonExistentSubscription"}, WantDBData: map[string]interface{}{"name": "NonExistentSubscription"},
Assert: false, Assert: false,
Input: GenerateInputJSON( Input: GenerateInputJSON(
customizeSubscription(func(subscription MembershipData) MembershipData { customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Subscription.Name = "NonExistentSubscription" subscription.Name = "NonExistentSubscription"
return subscription return subscription
})), })),
}, },
{ {
SetupCookie: func(req *http.Request) {
req.AddCookie(MemberCookie)
},
Name: "Correct Update but unauthorized",
WantResponse: http.StatusUnauthorized,
WantDBData: map[string]interface{}{"name": "Premium", "details": "Altered Details"},
Assert: false,
Input: GenerateInputJSON(
customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Details = "Altered Details"
subscription.Conditions = "Some Condition"
subscription.IncludedPerYear = 0
subscription.IncludedPerMonth = 1
return subscription
})),
},
{
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Correct Update should pass", Name: "Correct Update should pass",
WantResponse: http.StatusAccepted, WantResponse: http.StatusAccepted,
WantDBData: map[string]interface{}{"name": "Premium", "details": "Altered Details"}, WantDBData: map[string]interface{}{"name": "Premium", "details": "Altered Details"},
Assert: true, Assert: true,
Input: GenerateInputJSON( Input: GenerateInputJSON(
customizeSubscription(func(subscription MembershipData) MembershipData { customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Subscription.Details = "Altered Details" subscription.Details = "Altered Details"
subscription.Subscription.Conditions = "Some Condition" subscription.Conditions = "Some Condition"
subscription.Subscription.IncludedPerYear = 0 subscription.IncludedPerYear = 0
subscription.Subscription.IncludedPerMonth = 1 subscription.IncludedPerMonth = 1
return subscription return subscription
})), })),
}, },
@@ -334,58 +388,84 @@ func getSubscriptionUpdateData() []UpdateSubscriptionTest {
func getSubscriptionDeleteData() []DeleteSubscriptionTest { func getSubscriptionDeleteData() []DeleteSubscriptionTest {
var premiumSub, basicSub models.SubscriptionModel var premiumSub, basicSub models.Subscription
database.DB.Where("name = ?", "Premium").First(&premiumSub) database.DB.Where("name = ?", "Premium").First(&premiumSub)
database.DB.Where("name = ?", "Basic").First(&basicSub) database.DB.Where("name = ?", "Basic").First(&basicSub)
logger.Error.Printf("premiumSub.ID: %v", premiumSub.ID)
logger.Error.Printf("basicSub.ID: %v", basicSub.ID)
return []DeleteSubscriptionTest{ return []DeleteSubscriptionTest{
{ {
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Delete non-existent subscription should fail", Name: "Delete non-existent subscription should fail",
WantResponse: http.StatusExpectationFailed, WantResponse: http.StatusNotFound,
WantDBData: map[string]interface{}{"name": "NonExistentSubscription"}, WantDBData: map[string]interface{}{"name": "NonExistentSubscription"},
Assert: false, Assert: false,
Input: GenerateInputJSON( Input: GenerateInputJSON(
customizeSubscription(func(subscription MembershipData) MembershipData { customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Subscription.Name = "NonExistentSubscription" subscription.Name = "NonExistentSubscription"
subscription.Subscription.ID = basicSub.ID subscription.ID = basicSub.ID
logger.Error.Printf("subscription to delete: %#v", subscription)
return subscription return subscription
})), })),
}, },
{ {
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Delete subscription without name should fail", Name: "Delete subscription without name should fail",
WantResponse: http.StatusExpectationFailed, WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": ""}, WantDBData: map[string]interface{}{"name": ""},
Assert: false, Assert: false,
Input: GenerateInputJSON( Input: GenerateInputJSON(
customizeSubscription(func(subscription MembershipData) MembershipData { customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Subscription.Name = "" subscription.Name = ""
subscription.Subscription.ID = basicSub.ID subscription.ID = basicSub.ID
return subscription return subscription
})), })),
}, },
{ {
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Delete subscription with users should fail", Name: "Delete subscription with users should fail",
WantResponse: http.StatusExpectationFailed, WantResponse: http.StatusExpectationFailed,
WantDBData: map[string]interface{}{"name": "Basic"}, WantDBData: map[string]interface{}{"name": "Basic"},
Assert: true, Assert: true,
Input: GenerateInputJSON( Input: GenerateInputJSON(
customizeSubscription(func(subscription MembershipData) MembershipData { customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Subscription.Name = "Basic" subscription.Name = "Basic"
subscription.Subscription.ID = basicSub.ID subscription.ID = basicSub.ID
return subscription return subscription
})), })),
}, },
{ {
SetupCookie: func(req *http.Request) {
req.AddCookie(MemberCookie)
},
Name: "Delete valid subscription should succeed",
WantResponse: http.StatusUnauthorized,
WantDBData: map[string]interface{}{"name": "Premium"},
Assert: true,
Input: GenerateInputJSON(
customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Name = "Premium"
subscription.ID = premiumSub.ID
return subscription
})),
},
{
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Delete valid subscription should succeed", Name: "Delete valid subscription should succeed",
WantResponse: http.StatusOK, WantResponse: http.StatusOK,
WantDBData: map[string]interface{}{"name": "Premium"}, WantDBData: map[string]interface{}{"name": "Premium"},
Assert: false, Assert: false,
Input: GenerateInputJSON( Input: GenerateInputJSON(
customizeSubscription(func(subscription MembershipData) MembershipData { customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Subscription.Name = "Premium" subscription.Name = "Premium"
subscription.Subscription.ID = premiumSub.ID subscription.ID = premiumSub.ID
return subscription return subscription
})), })),
}, },

View File

@@ -0,0 +1,161 @@
package controllers
import (
"GoMembership/internal/constants"
"GoMembership/internal/utils"
"GoMembership/pkg/errors"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
func (uc *UserController) CreatePasswordHandler(c *gin.Context) {
requestUser, err := uc.Service.FromContext(c)
if err != nil {
utils.RespondWithError(c, err, "Couldn't get User from Request Context", http.StatusBadRequest, errors.Responses.Fields.General, errors.Responses.Keys.NoAuthToken)
return
}
if !requestUser.IsAdmin() {
utils.RespondWithError(c, errors.ErrNotAuthorized, "Requesting user not authorized to grant user access", http.StatusUnauthorized, errors.Responses.Fields.User, errors.Responses.Keys.Unauthorized)
return
}
// Expected data from the user
var input struct {
User struct {
ID uint `json:"id" binding:"required,numeric"`
} `json:"user"`
}
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandleValidationError(c, err)
return
}
// find user
user, err := uc.Service.FromID(&input.User.ID)
if err != nil {
utils.RespondWithError(c, err, "couldn't get user by id", http.StatusNotFound, errors.Responses.Fields.User, errors.Responses.Keys.NotFound)
return
}
// Deactivate user and reset Verification
user.Status = constants.DisabledStatus
v, err := user.SetVerification(constants.VerificationTypes.Password)
if err != nil {
utils.RespondWithError(c, err, "couldn't set verification", http.StatusInternalServerError, errors.Responses.Fields.User, errors.Responses.Keys.InternalServerError)
return
}
if _, err := uc.Service.Update(user); err != nil {
utils.RespondWithError(c, err, "Couldn't update user in createPasswordHandler", http.StatusInternalServerError, errors.Responses.Fields.General, errors.Responses.Keys.InternalServerError)
return
}
// send email
if err := uc.EmailService.SendGrantBackendAccessEmail(user, &v.VerificationToken); err != nil {
utils.RespondWithError(c, err, "Couldn't send grant backend access email", http.StatusInternalServerError, errors.Responses.Fields.General, errors.Responses.Keys.InternalServerError)
return
}
c.JSON(http.StatusAccepted, gin.H{
"message": "password_change_requested",
})
}
func (uc *UserController) RequestPasswordChangeHandler(c *gin.Context) {
// Expected data from the user
var input struct {
Email string `json:"email" binding:"required,email"`
}
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandleValidationError(c, err)
return
}
// find user
user, err := uc.Service.FromEmail(&input.Email)
if err != nil {
utils.RespondWithError(c, err, "couldn't get user by email", http.StatusNotFound, errors.Responses.Fields.User, errors.Responses.Keys.NotFound)
return
}
// check if user may change the password
if !user.IsVerified() {
utils.RespondWithError(c, errors.ErrNotAuthorized, "User password change request denied, user is not verified or disabled", http.StatusForbidden, errors.Responses.Fields.Login, errors.Responses.Keys.UserDisabled)
return
}
user.Status = constants.DisabledStatus
v, err := user.SetVerification(constants.VerificationTypes.Password)
if err != nil {
utils.RespondWithError(c, err, "couldn't set verification", http.StatusInternalServerError, errors.Responses.Fields.User, errors.Responses.Keys.InternalServerError)
return
}
if _, err := uc.Service.Update(user); err != nil {
utils.RespondWithError(c, err, "Couldn't update user in createPasswordHandler", http.StatusInternalServerError, errors.Responses.Fields.General, errors.Responses.Keys.InternalServerError)
return
}
// send email
if err := uc.EmailService.SendChangePasswordEmail(user, &v.VerificationToken); err != nil {
utils.RespondWithError(c, err, "Couldn't send change password email", http.StatusInternalServerError, errors.Responses.Fields.General, errors.Responses.Keys.InternalServerError)
return
}
c.JSON(http.StatusAccepted, gin.H{
"message": "password_change_requested",
})
}
func (uc *UserController) ChangePassword(c *gin.Context) {
// Expected data from the user
var input struct {
Password string `json:"password" binding:"required"`
Token string `json:"token" binding:"required"`
}
userIDint, err := strconv.Atoi(c.Param("id"))
if err != nil {
utils.RespondWithError(c, err, "Invalid user ID", http.StatusBadRequest, errors.Responses.Fields.User, errors.Responses.Keys.InvalidUserID)
return
}
userID := uint(userIDint)
user, err := uc.Service.FromID(&userID)
if err != nil {
utils.RespondWithError(c, err, "Couldn't find user", http.StatusNotFound, errors.Responses.Fields.User, errors.Responses.Keys.UserNotFoundWrongPassword)
return
}
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandleValidationError(c, err)
return
}
err = user.Verify(input.Token, constants.VerificationTypes.Password)
if err != nil {
utils.RespondWithError(c, err, "Couldn't verify user", http.StatusInternalServerError, errors.Responses.Fields.General, errors.Responses.Keys.InternalServerError)
return
}
user.Status = constants.ActiveStatus
user.Password = input.Password
// Get Gin's binding validator engine with all registered validators
validate := binding.Validator.Engine().(*validator.Validate)
// Validate the populated user struct
if err := validate.Struct(user); err != nil {
utils.HandleValidationError(c, err)
return
}
_, err = uc.Service.Update(user)
if err != nil {
utils.HandleUserUpdateError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"message": "password_changed",
})
}

View File

@@ -0,0 +1,211 @@
package controllers
import (
"GoMembership/internal/config"
"GoMembership/internal/constants"
"GoMembership/internal/database"
"GoMembership/internal/models"
"GoMembership/internal/utils"
"GoMembership/pkg/logger"
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
type TestContext struct {
router *gin.Engine
response *httptest.ResponseRecorder
user *models.User
}
func setupTestContext() (*TestContext, error) {
testEmail := "john.doe@example.com"
user, err := Uc.Service.FromEmail(&testEmail)
if err != nil {
logger.Error.Printf("error fetching user: %#v", err)
return nil, err
}
return &TestContext{
router: gin.Default(),
response: httptest.NewRecorder(),
user: user,
}, nil
}
func testCreatePasswordHandler(t *testing.T) {
invalidCookie := http.Cookie{
Name: "jwt",
Value: "invalid.token.here",
}
tc, err := setupTestContext()
if err != nil {
t.Fatal(err)
}
tc.router.POST("/password", Uc.CreatePasswordHandler)
requestBody := map[string]interface{}{
"user": map[string]interface{}{
"id": tc.user.ID,
},
}
body, _ := json.Marshal(requestBody)
t.Run("successful password creation request from admin", func(t *testing.T) {
req, _ := http.NewRequest("POST", "/password", bytes.NewBuffer(body))
req.AddCookie(AdminCookie)
tc.router.ServeHTTP(tc.response, req)
assert.Equal(t, http.StatusAccepted, tc.response.Code)
assert.JSONEq(t, `{"message":"password_change_requested"}`, tc.response.Body.String())
err = checkEmailDelivery(tc.user, true)
assert.NoError(t, err)
})
// test token and password change
testChangePassword(t, tc)
logger.Error.Printf("__________END RESULTS---------")
tc.response = httptest.NewRecorder()
t.Run("failed password creation request from member", func(t *testing.T) {
req, _ := http.NewRequest("POST", "/password", bytes.NewBuffer(body))
req.AddCookie(MemberCookie)
tc.router.ServeHTTP(tc.response, req)
logger.Error.Printf("Test results for %#v", t.Name())
assert.Equal(t, http.StatusUnauthorized, tc.response.Code)
assert.JSONEq(t, `{"errors":[{"field":"user.user","key":"server.error.unauthorized"}]}`, tc.response.Body.String())
err = checkEmailDelivery(tc.user, false)
assert.NoError(t, err)
})
logger.Error.Printf("__________END RESULTS---------")
tc.response = httptest.NewRecorder()
t.Run("failed password creation request for invalid cookie", func(t *testing.T) {
req, _ := http.NewRequest("POST", "/password", bytes.NewBuffer(body))
req.AddCookie(&invalidCookie)
tc.router.ServeHTTP(tc.response, req)
logger.Error.Printf("Test results for %#v", t.Name())
assert.Equal(t, http.StatusBadRequest, tc.response.Code)
assert.Contains(t, tc.response.Body.String(), `server.error.no_auth_token`)
err = checkEmailDelivery(tc.user, false)
assert.NoError(t, err)
})
logger.Error.Printf("__________END RESULTS---------")
tc.response = httptest.NewRecorder()
}
func testChangePassword(t *testing.T, tc *TestContext) {
var verification models.Verification
result := database.DB.Where("user_id = ? AND type = ?", tc.user.ID, constants.VerificationTypes.Password).First(&verification)
assert.NoError(t, result.Error)
requestBody := map[string]interface{}{
"password": "new-pas9247A@!sword",
"token": verification.VerificationToken,
}
body, _ := json.Marshal(requestBody)
tc.router.PUT("/users/:id/password", Uc.ChangePassword)
tc.response = httptest.NewRecorder()
t.Run("valid password change", func(t *testing.T) {
req, _ := http.NewRequest("PUT", fmt.Sprintf("/users/%v/password", tc.user.ID), bytes.NewBuffer(body))
tc.router.ServeHTTP(tc.response, req)
assert.Equal(t, http.StatusOK, tc.response.Code)
assert.JSONEq(t, `{"message":"password_changed"}`, tc.response.Body.String())
})
tc.response = httptest.NewRecorder()
//User should now be deactivated. Should lack privileges.
t.Run("user lacks privileges", func(t *testing.T) {
req, _ := http.NewRequest("POST", "/password", bytes.NewBuffer(body))
tc.router.ServeHTTP(tc.response, req)
logger.Error.Printf("Test results for %#v", t.Name())
assert.Equal(t, http.StatusBadRequest, tc.response.Code)
assert.Contains(t, tc.response.Body.String(), `server.error.no_auth_token`)
})
logger.Error.Printf("__________END RESULTS---------")
t.Run("invalid user ID", func(t *testing.T) {
req, _ := http.NewRequest("PUT", "/users/invalid/password", nil)
tc.router.ServeHTTP(tc.response, req)
assert.Equal(t, http.StatusBadRequest, tc.response.Code)
})
t.Run("non existant user ID", func(t *testing.T) {
req, _ := http.NewRequest("PUT", "/users/999/password", nil)
tc.router.ServeHTTP(tc.response, req)
assert.Equal(t, http.StatusBadRequest, tc.response.Code)
})
}
func checkEmailDelivery(user *models.User, wantsSuccess bool) error {
//check for email delivery
messages := utils.SMTPGetMessages()
for _, message := range messages {
mail, err := utils.DecodeMail(message.MsgRequest())
if err != nil {
logger.Error.Printf("Error in validateUser: %#v", err)
return err
}
if strings.Contains(mail.Subject, constants.MailChangePasswordSubject) || strings.Contains(mail.Subject, constants.MailGrantBackendAccessSubject) {
if err := checkPasswordMail(mail, user); err != nil && wantsSuccess {
logger.Error.Printf("Error in checkEmailDelivery mail: %#v", err)
return err
}
} else {
return fmt.Errorf("Subject not expected: %v", mail.Subject)
}
}
return nil
}
func checkPasswordMail(message *utils.Email, user *models.User) error {
var verification models.Verification
result := database.DB.Where("user_id = ? AND type = ?", user.ID, constants.VerificationTypes.Password).First(&verification)
if result.Error != nil {
return result.Error
}
logger.Error.Printf("user id: %v token: %#v", user.ID, verification.VerificationToken)
re := regexp.MustCompile(`"([^"]*token[^"]*)"`)
// Find the matching URL in the email content
match := re.FindStringSubmatch(message.Body)
if len(match) == 0 {
return fmt.Errorf("No change Password link found in email body: %#v", message.Body)
}
tokenURL, err := url.QueryUnescape(match[1])
if err != nil {
return fmt.Errorf("Error decoding URL: %v", err)
}
logger.Info.Printf("TokenURL: %#v", tokenURL)
if !strings.Contains(message.To, user.Email) {
return fmt.Errorf("Password Information didn't reach user! Recipient was: %v instead of %v", message.To, user.Email)
}
if !strings.Contains(message.From, config.SMTP.User) {
return fmt.Errorf("Password Information was sent from unexpected address! Sender was: %v instead of %v", message.From, config.SMTP.User)
}
//Check if all the relevant data has been passed to the mail.
if !strings.Contains(message.Body, user.FirstName+" "+user.LastName) {
return fmt.Errorf("User first and last name(%v) has not been rendered in password mail.", user.FirstName+" "+user.LastName)
}
if !strings.Contains(message.Body, verification.VerificationToken) {
return fmt.Errorf("Token(%v) has not been rendered in password mail.", verification.VerificationToken)
}
if strings.Trim(tokenURL, " ") != fmt.Sprintf("%v%v/auth/password/change/%v?token=%v", config.Site.BaseURL, config.Site.FrontendPath, user.ID, verification.VerificationToken) {
return fmt.Errorf("Token has not been rendered correctly in password mail: %v%v/auth/password/change/%v?token=%v", config.Site.BaseURL, config.Site.FrontendPath, user.ID, verification.VerificationToken)
}
return nil
}

View File

@@ -0,0 +1,376 @@
package controllers
import (
"GoMembership/internal/config"
"GoMembership/internal/constants"
"GoMembership/internal/middlewares"
"GoMembership/internal/models"
"GoMembership/internal/services"
"GoMembership/internal/utils"
"GoMembership/internal/validation"
"fmt"
"strconv"
"strings"
"net/http"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"GoMembership/pkg/errors"
"GoMembership/pkg/logger"
)
type UserController struct {
Service services.UserServiceInterface
EmailService *services.EmailService
ConsentService services.ConsentServiceInterface
BankAccountService services.BankAccountServiceInterface
MembershipService services.MembershipServiceInterface
LicenceService services.LicenceServiceInterface
}
type RegistrationData struct {
User models.User `json:"user"`
}
func (uc *UserController) CurrentUserHandler(c *gin.Context) {
requestUser, err := uc.Service.FromContext(c)
if err != nil {
utils.RespondWithError(c, err, "Error extracting user from context in CurrentUserHandler", http.StatusBadRequest, errors.Responses.Fields.User, errors.Responses.Keys.NoAuthToken)
return
}
c.JSON(http.StatusOK, gin.H{
"user": requestUser.Safe(),
})
}
func (uc *UserController) GetAllUsers(c *gin.Context) {
requestUser, err := uc.Service.FromContext(c)
if err != nil {
utils.RespondWithError(c, err, "Error extracting user from context in UpdateHandler", http.StatusBadRequest, errors.Responses.Fields.User, errors.Responses.Keys.NoAuthToken)
return
}
if !requestUser.HasPrivilege(constants.Priviliges.View) {
utils.RespondWithError(c, errors.ErrNotAuthorized, fmt.Sprintf("Not allowed to handle all users. RoleID(%v)<Privilige(%v)", requestUser.RoleID, constants.Priviliges.View), http.StatusForbidden, errors.Responses.Fields.User, errors.Responses.Keys.Unauthorized)
return
}
users, err := uc.Service.GetUsers(nil)
if err != nil {
utils.RespondWithError(c, err, "Error getting all users", http.StatusInternalServerError, errors.Responses.Fields.User, errors.Responses.Keys.InternalServerError)
return
}
// Create a slice to hold the safe user representations
safeUsers := make([]map[string]interface{}, len(*users))
// Convert each user to its safe representation
for i, user := range *users {
safeUsers[i] = user.Safe()
}
c.JSON(http.StatusOK, gin.H{
"users": safeUsers,
})
}
func (uc *UserController) UpdateHandler(c *gin.Context) {
// 1. Extract and validate the user ID from the route
requestUser, err := uc.Service.FromContext(c)
if err != nil {
utils.RespondWithError(c, err, "Error extracting user from context in UpdateHandler", http.StatusBadRequest, errors.Responses.Fields.User, errors.Responses.Keys.NoAuthToken)
return
}
var updateData RegistrationData
if err := c.ShouldBindJSON(&updateData); err != nil {
if updateData.User.Password != "" {
logger.Error.Printf("u.password: %#v", updateData.User.Password)
}
utils.HandleValidationError(c, err)
return
}
user := updateData.User
if !requestUser.HasPrivilege(constants.Priviliges.Update) && user.ID != requestUser.ID {
utils.RespondWithError(c, errors.ErrNotAuthorized, "Not allowed to update user", http.StatusForbidden, errors.Responses.Fields.User, errors.Responses.Keys.Unauthorized)
return
}
if requestUser.IsMember() {
existingUser, err := uc.Service.FromID(&user.ID)
if err != nil {
utils.RespondWithError(c, err, "Error finding an existing user", http.StatusNotFound, errors.Responses.Fields.User, errors.Responses.Keys.NotFound)
return
}
// deleting existing Users Password to prevent it from being recognized as changed in any case. (Incoming Password is empty if not changed)
existingUser.Password = ""
if err := validation.FilterAllowedStructFields(&user, existingUser, constants.MemberUpdateFields, ""); err != nil {
if err.Error() == "Not authorized" {
utils.RespondWithError(c, errors.ErrNotAuthorized, "Trying to update unauthorized fields", http.StatusUnauthorized, errors.Responses.Fields.User, errors.Responses.Keys.Unauthorized)
} else {
utils.RespondWithError(c, err, "Error filtering users input fields", http.StatusInternalServerError, errors.Responses.Fields.User, errors.Responses.Keys.InternalServerError)
}
return
}
}
updatedUser, err := uc.Service.Update(&user)
if err != nil {
utils.HandleUserUpdateError(c, err)
return
}
logger.Info.Printf("User %v updated successfully by user %v", updatedUser.Email, requestUser.Email)
c.JSON(http.StatusAccepted, gin.H{"message": "User updated successfully", "user": updatedUser.Safe()})
}
func (uc *UserController) DeleteUser(c *gin.Context) {
requestUser, err := uc.Service.FromContext(c)
if err != nil {
utils.RespondWithError(c, err, "Error extracting user from context in DeleteUser", http.StatusBadRequest, errors.Responses.Fields.User, errors.Responses.Keys.NoAuthToken)
return
}
type deleteData struct {
ID uint `json:"id" binding:"required,numeric"`
}
var data deleteData
if err := c.ShouldBindJSON(&data); err != nil {
utils.HandleValidationError(c, err)
return
}
if !requestUser.HasPrivilege(constants.Priviliges.Delete) && data.ID != requestUser.ID {
utils.RespondWithError(c, errors.ErrNotAuthorized, "Not allowed to delete user", http.StatusForbidden, errors.Responses.Fields.User, errors.Responses.Keys.Unauthorized)
return
}
logger.Error.Printf("Deleting user: %v", data)
if err := uc.Service.Delete(&data.ID); err != nil {
utils.HandleDeleteUserError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"message": "User deleted successfully"})
}
func (uc *UserController) LogoutHandler(c *gin.Context) {
tokenString, err := c.Cookie("jwt")
if err != nil {
logger.Error.Printf("unable to get token from cookie: %#v", err)
}
middlewares.InvalidateSession(tokenString)
c.SetCookie("jwt", "", -1, "/", "", true, true)
c.JSON(http.StatusOK, gin.H{"message": "Logged out successfully"})
}
func (uc *UserController) LoginHandler(c *gin.Context) {
var input struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&input); err != nil {
utils.RespondWithError(c, err, "Invalid JSON or malformed request", http.StatusBadRequest, errors.Responses.Fields.General, errors.Responses.Keys.Invalid)
return
}
user, err := uc.Service.FromEmail(&input.Email)
if err != nil {
utils.RespondWithError(c, err, "Login Error; user not found", http.StatusNotFound,
errors.Responses.Fields.Login,
errors.Responses.Keys.UserNotFoundWrongPassword)
return
}
if !user.IsVerified() {
utils.RespondWithError(c, fmt.Errorf("User banned from login or not verified %v %v", user.FirstName, user.LastName),
"Login Error; user is disabled or not verified",
http.StatusNotAcceptable,
errors.Responses.Fields.Login,
errors.Responses.Keys.UserDisabled)
return
}
ok, err := user.PasswordMatches(input.Password)
if err != nil {
utils.RespondWithError(c, err, "Login Error; password incorrect", http.StatusInternalServerError, errors.Responses.Fields.Login, errors.Responses.Keys.InternalServerError)
return
}
if !ok {
utils.RespondWithError(c, fmt.Errorf("%v %v(%v)", user.FirstName, user.LastName, user.Email),
"Login Error; wrong password",
http.StatusNotAcceptable,
errors.Responses.Fields.Login,
errors.Responses.Keys.UserNotFoundWrongPassword)
return
}
// "user_id": user.ID,
// "role_id": user.RoleID,
claims := map[string]interface{}{"user_id": user.ID, "role_id": user.RoleID}
token, err := middlewares.GenerateToken(&config.Auth.JWTSecret, claims, "")
if err != nil {
utils.RespondWithError(c, err, "Error generating token in LoginHandler", http.StatusInternalServerError, errors.Responses.Fields.Login, errors.Responses.Keys.JwtGenerationFailed)
return
}
utils.SetCookie(c, token)
c.JSON(http.StatusOK, gin.H{
"message": "Login successful",
"user_id": user.ID,
})
}
func (uc *UserController) RegisterUser(c *gin.Context) {
var regData RegistrationData
if err := c.ShouldBindJSON(&regData); err != nil {
logger.Error.Printf("Failed initial Binding: %#v", &regData.User.Membership)
logger.Error.Printf("Failed initial Binding: %#v", &regData.User.Membership.Subscription)
utils.HandleValidationError(c, err)
return
}
logger.Info.Printf("Registering user %v", regData.User.Email)
selectedModel, err := uc.MembershipService.GetSubscriptionByName(&regData.User.Membership.Subscription.Name)
if err != nil {
utils.RespondWithError(c, err, "Error in Registeruser, couldn't get selected model", http.StatusNotFound, errors.Responses.Fields.Subscription, errors.Responses.Keys.InvalidSubscription)
return
}
regData.User.Membership.Subscription = *selectedModel
// Get Gin's binding validator engine with all registered validators
validate := binding.Validator.Engine().(*validator.Validate)
// Validate the populated user struct
if err := validate.Struct(regData.User); err != nil {
utils.HandleValidationError(c, err)
return
}
if regData.User.Membership.Subscription.Name == constants.SupporterSubscriptionName {
regData.User.RoleID = constants.Roles.Supporter
} else {
regData.User.RoleID = constants.Roles.Member
}
// Register User
id, token, err := uc.Service.Register(&regData.User)
if err != nil {
if strings.Contains(err.Error(), "UNIQUE constraint failed:") {
utils.RespondWithError(c, err, "Error in RegisterUser, couldn't register user", http.StatusConflict, errors.Responses.Fields.Email, errors.Responses.Keys.Duplicate)
} else {
utils.RespondWithError(c, err, "Error in RegisterUser, couldn't register user", http.StatusConflict, errors.Responses.Fields.General, errors.Responses.Keys.InternalServerError)
}
return
}
regData.User.ID = id
// if this is a supporter don't send mails and he never did give any consent. So stop here
if regData.User.IsSupporter() {
c.JSON(http.StatusCreated, gin.H{
"message": "Supporter Registration successuful",
"id": regData.User.ID,
})
return
}
// Register Consents
var consents = [2]models.Consent{
{
FirstName: regData.User.FirstName,
LastName: regData.User.LastName,
Email: regData.User.Email,
ConsentType: "TermsOfService",
UserID: &regData.User.ID,
},
{
FirstName: regData.User.FirstName,
LastName: regData.User.LastName,
Email: regData.User.Email,
ConsentType: "Privacy",
UserID: &regData.User.ID,
},
}
for _, consent := range consents {
_, err = uc.ConsentService.RegisterConsent(&consent)
if err != nil {
utils.RespondWithError(c, err, "Error in RegisterUser, couldn't register consent", http.StatusInternalServerError, errors.Responses.Fields.General, errors.Responses.Keys.InternalServerError)
return
}
}
logger.Error.Printf("Sending Verification mail to user with id: %#v", id)
// Send notifications
if err := uc.EmailService.SendVerificationEmail(&regData.User, &token); err != nil {
utils.RespondWithError(c, err, "Error in RegisterUser, couldn't send verification email", http.StatusInternalServerError, errors.Responses.Fields.Email, errors.Responses.Keys.UndeliveredVerificationMail)
// TODO Notify Admin
}
// Notify admin of new user registration
if err := uc.EmailService.SendRegistrationNotification(&regData.User); err != nil {
logger.Error.Printf("Failed to notify admin of new user(%v) registration: %v", regData.User.Email, err)
// Proceed without returning error since user registration is successful
// TODO Notify Admin
}
c.JSON(http.StatusCreated, gin.H{
"message": "Registration successuful",
"id": regData.User.ID,
})
}
func (uc *UserController) VerifyMailHandler(c *gin.Context) {
token := c.Query("token")
if token == "" {
logger.Error.Println("Missing token to verify mail")
c.HTML(http.StatusBadRequest, "verification_error.html", gin.H{"ErrorMessage": "Missing token"})
return
}
userIDint, err := strconv.Atoi(c.Param("id"))
if err != nil {
logger.Error.Println("Missing user ID to verify mail")
c.HTML(http.StatusBadRequest, "verification_error.html", gin.H{"ErrorMessage": "Missing user"})
return
}
userID := uint(userIDint)
user, err := uc.Service.FromID(&userID)
if err != nil {
logger.Error.Printf("Couldn't find user in verifyMailHandler: %#v", err)
c.HTML(http.StatusBadRequest, "verification_error.html", gin.H{"ErrorMessage": "Couldn't find user"})
return
}
err = user.Verify(token, constants.VerificationTypes.Email)
if err != nil {
logger.Error.Printf("Couldn't find user verification in verifyMailHandler: %v", err)
c.HTML(http.StatusBadRequest, "verification_error.html", gin.H{"ErrorMessage": "Couldn't find user verification request"})
return
}
user.Status = constants.VerifiedStatus
user.Password = ""
updatedUser, err := uc.Service.Update(user)
if err != nil {
logger.Error.Printf("Failed to update user(%v) after verification: %v", user.Email, err)
c.HTML(http.StatusInternalServerError, "verification_error.html", gin.H{"ErrorMessage": "Internal server error, couldn't verify user"})
return
}
logger.Info.Printf("Verified User: %#v", updatedUser.Email)
uc.EmailService.SendWelcomeEmail(user)
c.HTML(http.StatusOK, "verification_success.html", gin.H{"FirstName": user.FirstName})
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -20,6 +21,7 @@ import (
"GoMembership/internal/config" "GoMembership/internal/config"
"GoMembership/internal/constants" "GoMembership/internal/constants"
"GoMembership/internal/database"
"GoMembership/internal/middlewares" "GoMembership/internal/middlewares"
"GoMembership/internal/models" "GoMembership/internal/models"
"GoMembership/internal/repositories" "GoMembership/internal/repositories"
@@ -72,14 +74,44 @@ func testUserController(t *testing.T) {
} }
}) })
} }
// activate user for login
database.DB.Model(&models.User{}).Where("email = ?", "john.doe@example.com").Update("status", constants.ActiveStatus)
loginEmail := testLoginHandler(t)
testCurrentUserHandler(t, loginEmail)
// creating a admin cookie
c, w, _ := GetMockedJSONContext([]byte(`{
"email": "admin@example.com",
"password": "securepassword"
}`), "/login")
loginEmail, loginCookie := testLoginHandler(t) Uc.LoginHandler(c)
logoutCookie := testCurrentUserHandler(t, loginEmail, loginCookie)
testUpdateUser(t, loginCookie) var response map[string]interface{}
testLogoutHandler(t, logoutCookie) err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, "Login successful", response["message"])
for _, cookie := range w.Result().Cookies() {
if cookie.Name == "jwt" {
AdminCookie = cookie
tokenString := AdminCookie.Value
_, claims, err := middlewares.ExtractContentFrom(tokenString)
assert.NoError(t, err, "Failed getting cookie string")
jwtUserID := uint((*claims)["user_id"].(float64))
user, err := Uc.Service.FromID(&jwtUserID)
assert.NoError(t, err, "Failed getting cookie string")
logger.Error.Printf("ADMIN USER: %#v", user)
break
}
}
assert.NotEmpty(t, AdminCookie)
testUpdateUser(t)
testLogoutHandler(t)
testCreatePasswordHandler(t)
} }
func testLogoutHandler(t *testing.T, loginCookie http.Cookie) { func testLogoutHandler(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -89,7 +121,7 @@ func testLogoutHandler(t *testing.T, loginCookie http.Cookie) {
{ {
name: "Logout with valid cookie", name: "Logout with valid cookie",
setupCookie: func(req *http.Request) { setupCookie: func(req *http.Request) {
req.AddCookie(&loginCookie) req.AddCookie(MemberCookie)
}, },
expectedStatus: http.StatusOK, expectedStatus: http.StatusOK,
}, },
@@ -147,9 +179,8 @@ func testLogoutHandler(t *testing.T, loginCookie http.Cookie) {
} }
} }
func testLoginHandler(t *testing.T) (string, http.Cookie) { func testLoginHandler(t *testing.T) string {
// This test should run after the user registration test // This test should run after the user registration test
var loginCookie http.Cookie
var loginInput loginInput var loginInput loginInput
t.Run("LoginHandler", func(t *testing.T) { t.Run("LoginHandler", func(t *testing.T) {
// Test cases // Test cases
@@ -163,7 +194,7 @@ func testLoginHandler(t *testing.T) (string, http.Cookie) {
name: "Valid login", name: "Valid login",
input: `{ input: `{
"email": "john.doe@example.com", "email": "john.doe@example.com",
"password": "password123" "password": "passw@#$#%$!-ord123"
}`, }`,
wantStatusCode: http.StatusOK, wantStatusCode: http.StatusOK,
wantToken: true, wantToken: true,
@@ -172,7 +203,7 @@ func testLoginHandler(t *testing.T) (string, http.Cookie) {
name: "Invalid email", name: "Invalid email",
input: `{ input: `{
"email": "nonexistent@example.com", "email": "nonexistent@example.com",
"password": "password123" "password": "passw@#$#%$!-ord123"
}`, }`,
wantStatusCode: http.StatusNotFound, wantStatusCode: http.StatusNotFound,
wantToken: false, wantToken: false,
@@ -190,7 +221,7 @@ func testLoginHandler(t *testing.T) (string, http.Cookie) {
for _, tt := range tests { for _, tt := range tests {
logger.Error.Print("==============================================================") logger.Error.Print("==============================================================")
logger.Error.Printf("Testing : %v", tt.name) logger.Error.Printf("Login Testing : %v", tt.name)
logger.Error.Print("==============================================================") logger.Error.Print("==============================================================")
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Setup // Setup
@@ -211,15 +242,23 @@ func testLoginHandler(t *testing.T) (string, http.Cookie) {
assert.Equal(t, "Login successful", response["message"]) assert.Equal(t, "Login successful", response["message"])
for _, cookie := range w.Result().Cookies() { for _, cookie := range w.Result().Cookies() {
if cookie.Name == "jwt" { if cookie.Name == "jwt" {
loginCookie = *cookie MemberCookie = cookie
tokenString := cookie.Value
_, claims, err := middlewares.ExtractContentFrom(tokenString)
assert.NoError(t, err, "FAiled getting cookie string")
jwtUserID := uint((*claims)["user_id"].(float64))
_, err = Uc.Service.FromID(&jwtUserID)
assert.NoError(t, err, "FAiled getting cookie string")
// logger.Error.Printf("cookie user: %#v", user)
err = json.Unmarshal([]byte(tt.input), &loginInput) err = json.Unmarshal([]byte(tt.input), &loginInput)
assert.NoError(t, err, "Failed to unmarshal input JSON") assert.NoError(t, err, "Failed to unmarshal input JSON")
break break
} }
} }
assert.NotEmpty(t, loginCookie) assert.NotEmpty(t, MemberCookie)
} else { } else {
assert.Contains(t, response, "errors") assert.Contains(t, response, "errors")
assert.NotEmpty(t, response["errors"]) assert.NotEmpty(t, response["errors"])
@@ -228,10 +267,10 @@ func testLoginHandler(t *testing.T) (string, http.Cookie) {
} }
}) })
return loginInput.Email, loginCookie return loginInput.Email
} }
func testCurrentUserHandler(t *testing.T, loginEmail string, loginCookie http.Cookie) http.Cookie { func testCurrentUserHandler(t *testing.T, loginEmail string) http.Cookie {
// This test should run after the user login test // This test should run after the user login test
invalidCookie := http.Cookie{ invalidCookie := http.Cookie{
Name: "jwt", Name: "jwt",
@@ -248,7 +287,7 @@ func testCurrentUserHandler(t *testing.T, loginEmail string, loginCookie http.Co
{ {
name: "With valid cookie", name: "With valid cookie",
setupCookie: func(req *http.Request) { setupCookie: func(req *http.Request) {
req.AddCookie(&loginCookie) req.AddCookie(MemberCookie)
}, },
expectedUserMail: loginEmail, expectedUserMail: loginEmail,
expectedStatus: http.StatusOK, expectedStatus: http.StatusOK,
@@ -276,7 +315,7 @@ func testCurrentUserHandler(t *testing.T, loginEmail string, loginCookie http.Co
setupCookie: func(req *http.Request) {}, setupCookie: func(req *http.Request) {},
expectedStatus: http.StatusUnauthorized, expectedStatus: http.StatusUnauthorized,
expectedErrors: []map[string]string{ expectedErrors: []map[string]string{
{"field": "general", "key": "server.error.no_auth_token"}, {"field": "server.general", "key": "server.error.no_auth_token"},
}, },
}, },
{ {
@@ -286,7 +325,7 @@ func testCurrentUserHandler(t *testing.T, loginEmail string, loginCookie http.Co
}, },
expectedStatus: http.StatusUnauthorized, expectedStatus: http.StatusUnauthorized,
expectedErrors: []map[string]string{ expectedErrors: []map[string]string{
{"field": "general", "key": "server.error.no_auth_token"}, {"field": "server.general", "key": "server.error.no_auth_token"},
}, },
}, },
} }
@@ -313,7 +352,7 @@ func testCurrentUserHandler(t *testing.T, loginEmail string, loginCookie http.Co
if tt.expectedStatus == http.StatusOK { if tt.expectedStatus == http.StatusOK {
var response struct { var response struct {
User models.User `json:"user"` User models.User `json:"user"`
Subscriptions []models.SubscriptionModel `json:"subscriptions"` Subscriptions []models.Subscription `json:"subscriptions"`
} }
err := json.Unmarshal(w.Body.Bytes(), &response) err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err) assert.NoError(t, err)
@@ -328,7 +367,7 @@ func testCurrentUserHandler(t *testing.T, loginEmail string, loginCookie http.Co
} }
if tt.expectNewCookie { if tt.expectNewCookie {
assert.NotNil(t, newCookie, "New cookie should be set for expired token") assert.NotNil(t, newCookie, "New cookie should be set for expired token")
assert.NotEqual(t, loginCookie.Value, newCookie.Value, "Cookie value should be different") assert.NotEqual(t, MemberCookie.Value, newCookie.Value, "Cookie value should be different")
assert.True(t, newCookie.MaxAge > 0, "New cookie should not be expired") assert.True(t, newCookie.MaxAge > 0, "New cookie should not be expired")
} else { } else {
assert.Nil(t, newCookie, "No new cookie should be set for non-expired token") assert.Nil(t, newCookie, "No new cookie should be set for non-expired token")
@@ -354,7 +393,7 @@ func testCurrentUserHandler(t *testing.T, loginEmail string, loginCookie http.Co
}) })
} }
return loginCookie return *MemberCookie
} }
func validateUser(assert bool, wantDBData map[string]interface{}) error { func validateUser(assert bool, wantDBData map[string]interface{}) error {
@@ -362,23 +401,30 @@ func validateUser(assert bool, wantDBData map[string]interface{}) error {
if err != nil { if err != nil {
return fmt.Errorf("Error in database ops: %#v", err) return fmt.Errorf("Error in database ops: %#v", err)
} }
if assert != (len(*users) != 0) { if assert != (len(*users) != 0) {
return fmt.Errorf("User entry query didn't met expectation: %v != %#v", assert, *users) return fmt.Errorf("User entry query didn't met expectation: %v != %#v", assert, *users)
} }
if assert { if assert {
user := (*users)[0] user := (*users)[0]
// Check for mandate reference // Check for mandate reference
if user.BankAccount.MandateReference == "" {
if user.BankAccount.IBAN != "" && user.BankAccount.MandateReference == "" {
return fmt.Errorf("Mandate reference not generated for user: %s", user.Email) return fmt.Errorf("Mandate reference not generated for user: %s", user.Email)
} else if user.BankAccount.IBAN == "" && user.BankAccount.MandateReference != "" {
return fmt.Errorf("Mandate reference generated without IBAN for user: %s", user.Email)
} }
// Validate mandate reference format // Validate mandate reference format
expected := user.GenerateMandateReference() expected := user.BankAccount.GenerateMandateReference(user.ID)
if !strings.HasPrefix(user.BankAccount.MandateReference, expected) { if !strings.HasPrefix(user.BankAccount.MandateReference, expected) {
return fmt.Errorf("Mandate reference is invalid. Expected: %s, Got: %s", expected, user.BankAccount.MandateReference) return fmt.Errorf("Mandate reference is invalid. Expected: %s, Got: %s", expected, user.BankAccount.MandateReference)
} }
// Supoorter don't get mails
if user.IsSupporter() {
return nil
}
//check for email delivery //check for email delivery
messages := utils.SMTPGetMessages() messages := utils.SMTPGetMessages()
for _, message := range messages { for _, message := range messages {
@@ -413,18 +459,18 @@ func validateUser(assert bool, wantDBData map[string]interface{}) error {
return nil return nil
} }
func testUpdateUser(t *testing.T, loginCookie http.Cookie) { func testUpdateUser(t *testing.T) {
invalidCookie := http.Cookie{ invalidCookie := http.Cookie{
Name: "jwt", Name: "jwt",
Value: "invalid.token.here", Value: "invalid.token.here",
} }
// Get the user we just created // Get the user we just created
users, err := Uc.Service.GetUsers(map[string]interface{}{"email": "john.doe@example.com"}) johnsMail := "john.doe@example.com"
if err != nil || len(*users) == 0 { user, err := Uc.Service.FromEmail(&johnsMail)
if err != nil {
t.Fatalf("Failed to get test user: %v", err) t.Fatalf("Failed to get test user: %v", err)
} }
user := (*users)[0]
if user.Licence == nil { if user.Licence == nil {
user.Licence = &models.Licence{ user.Licence = &models.Licence{
Number: "Z021AB37X13", Number: "Z021AB37X13",
@@ -437,13 +483,14 @@ func testUpdateUser(t *testing.T, loginCookie http.Cookie) {
name string name string
setupCookie func(*http.Request) setupCookie func(*http.Request)
updateFunc func(*models.User) updateFunc func(*models.User)
expectedReturn func(*models.User)
expectedStatus int expectedStatus int
expectedErrors []map[string]string expectedErrors []map[string]string
}{ }{
{ {
name: "Valid Update", name: "Valid Admin Update",
setupCookie: func(req *http.Request) { setupCookie: func(req *http.Request) {
req.AddCookie(&loginCookie) req.AddCookie(AdminCookie)
}, },
updateFunc: func(u *models.User) { updateFunc: func(u *models.User) {
u.Password = "" u.Password = ""
@@ -466,13 +513,13 @@ func testUpdateUser(t *testing.T, loginCookie http.Cookie) {
}, },
expectedStatus: http.StatusUnauthorized, expectedStatus: http.StatusUnauthorized,
expectedErrors: []map[string]string{ expectedErrors: []map[string]string{
{"field": "general", "key": "server.error.no_auth_token"}, {"field": "server.general", "key": "server.error.no_auth_token"},
}, },
}, },
{ {
name: "Invalid Email Update", name: "Invalid Email Update",
setupCookie: func(req *http.Request) { setupCookie: func(req *http.Request) {
req.AddCookie(&loginCookie) req.AddCookie(MemberCookie)
}, },
updateFunc: func(u *models.User) { updateFunc: func(u *models.User) {
u.Password = "" u.Password = ""
@@ -486,10 +533,25 @@ func testUpdateUser(t *testing.T, loginCookie http.Cookie) {
{"field": "Email", "key": "server.validation.email"}, {"field": "Email", "key": "server.validation.email"},
}, },
}, },
{ {
name: "Change Number", name: "admin may change licence number",
setupCookie: func(req *http.Request) { setupCookie: func(req *http.Request) {
req.AddCookie(&loginCookie) req.AddCookie(AdminCookie)
},
updateFunc: func(u *models.User) {
u.Password = ""
u.FirstName = "John Updated"
u.LastName = "Doe Updated"
u.Phone = "01738484994"
u.Licence.Number = "B072RRE2I50"
},
expectedStatus: http.StatusAccepted,
},
{
name: "Change phone number",
setupCookie: func(req *http.Request) {
req.AddCookie(MemberCookie)
}, },
updateFunc: func(u *models.User) { updateFunc: func(u *models.User) {
u.Password = "" u.Password = ""
@@ -503,7 +565,7 @@ func testUpdateUser(t *testing.T, loginCookie http.Cookie) {
{ {
name: "Add category", name: "Add category",
setupCookie: func(req *http.Request) { setupCookie: func(req *http.Request) {
req.AddCookie(&loginCookie) req.AddCookie(MemberCookie)
}, },
updateFunc: func(u *models.User) { updateFunc: func(u *models.User) {
u.Password = "" u.Password = ""
@@ -514,14 +576,14 @@ func testUpdateUser(t *testing.T, loginCookie http.Cookie) {
var licenceRepo repositories.LicenceInterface = &repositories.LicenceRepository{} var licenceRepo repositories.LicenceInterface = &repositories.LicenceRepository{}
category, err := licenceRepo.FindCategoryByName("B") category, err := licenceRepo.FindCategoryByName("B")
assert.NoError(t, err) assert.NoError(t, err)
u.Licence.Categories = []models.Category{category} u.Licence.Categories = []*models.Category{&category}
}, },
expectedStatus: http.StatusAccepted, expectedStatus: http.StatusAccepted,
}, },
{ {
name: "Delete 1 and add 1 category", name: "Delete 1 and add 1 category",
setupCookie: func(req *http.Request) { setupCookie: func(req *http.Request) {
req.AddCookie(&loginCookie) req.AddCookie(MemberCookie)
}, },
updateFunc: func(u *models.User) { updateFunc: func(u *models.User) {
u.Password = "" u.Password = ""
@@ -533,14 +595,14 @@ func testUpdateUser(t *testing.T, loginCookie http.Cookie) {
category, err := licenceRepo.FindCategoryByName("A") category, err := licenceRepo.FindCategoryByName("A")
category2, err := licenceRepo.FindCategoryByName("BE") category2, err := licenceRepo.FindCategoryByName("BE")
assert.NoError(t, err) assert.NoError(t, err)
u.Licence.Categories = []models.Category{category, category2} u.Licence.Categories = []*models.Category{&category, &category2}
}, },
expectedStatus: http.StatusAccepted, expectedStatus: http.StatusAccepted,
}, },
{ {
name: "Delete 1 category", name: "Delete 1 category",
setupCookie: func(req *http.Request) { setupCookie: func(req *http.Request) {
req.AddCookie(&loginCookie) req.AddCookie(MemberCookie)
}, },
updateFunc: func(u *models.User) { updateFunc: func(u *models.User) {
u.Password = "" u.Password = ""
@@ -551,14 +613,14 @@ func testUpdateUser(t *testing.T, loginCookie http.Cookie) {
var licenceRepo repositories.LicenceInterface = &repositories.LicenceRepository{} var licenceRepo repositories.LicenceInterface = &repositories.LicenceRepository{}
category, err := licenceRepo.FindCategoryByName("A") category, err := licenceRepo.FindCategoryByName("A")
assert.NoError(t, err) assert.NoError(t, err)
u.Licence.Categories = []models.Category{category} u.Licence.Categories = []*models.Category{&category}
}, },
expectedStatus: http.StatusAccepted, expectedStatus: http.StatusAccepted,
}, },
{ {
name: "Delete all categories", name: "Delete all categories",
setupCookie: func(req *http.Request) { setupCookie: func(req *http.Request) {
req.AddCookie(&loginCookie) req.AddCookie(MemberCookie)
}, },
updateFunc: func(u *models.User) { updateFunc: func(u *models.User) {
u.Password = "" u.Password = ""
@@ -566,18 +628,19 @@ func testUpdateUser(t *testing.T, loginCookie http.Cookie) {
u.LastName = "Doe Updated" u.LastName = "Doe Updated"
u.Phone = "01738484994" u.Phone = "01738484994"
u.Licence.Number = "B072RRE2I50" u.Licence.Number = "B072RRE2I50"
u.Licence.Categories = []models.Category{} u.Licence.Categories = []*models.Category{}
}, },
expectedStatus: http.StatusAccepted, expectedStatus: http.StatusAccepted,
}, },
{ {
name: "User ID mismatch while not admin", name: "User ID mismatch while not admin",
setupCookie: func(req *http.Request) { setupCookie: func(req *http.Request) {
req.AddCookie(&loginCookie) req.AddCookie(MemberCookie)
}, },
updateFunc: func(u *models.User) { updateFunc: func(u *models.User) {
u.Password = "" u.Password = ""
u.ID = 1 u.ID = 1
u.FirstName = "John Updated"
u.LastName = "Doe Updated" u.LastName = "Doe Updated"
u.Phone = "01738484994" u.Phone = "01738484994"
u.Licence.Number = "B072RRE2I50" u.Licence.Number = "B072RRE2I50"
@@ -589,12 +652,63 @@ func testUpdateUser(t *testing.T, loginCookie http.Cookie) {
}, },
}, },
{ {
name: "Password Update", name: "Password Update low entropy should fail",
setupCookie: func(req *http.Request) { setupCookie: func(req *http.Request) {
req.AddCookie(&loginCookie) req.AddCookie(MemberCookie)
}, },
updateFunc: func(u *models.User) { updateFunc: func(u *models.User) {
u.FirstName = "John Updated"
u.LastName = "Doe Updated"
u.Phone = "01738484994"
u.Licence.Number = "B072RRE2I50"
u.Password = "newpassword"
},
expectedErrors: []map[string]string{
{"field": "server.validation.special server.validation.uppercase server.validation.numbers server.validation.longer", "key": "server.validation.insecure"},
},
expectedStatus: http.StatusBadRequest,
},
{
name: "Password Update",
setupCookie: func(req *http.Request) {
req.AddCookie(MemberCookie)
},
updateFunc: func(u *models.User) {
u.FirstName = "John Updated"
u.LastName = "Doe Updated"
u.Phone = "01738484994"
u.Licence.Number = "B072RRE2I50"
u.Password = "NewPa0293409@#-!ssword"
},
expectedReturn: func(u *models.User) {
u.Password = "" u.Password = ""
u.FirstName = "John Updated"
u.LastName = "Doe Updated"
u.Phone = "01738484994"
u.Licence.Number = "B072RRE2I50"
},
expectedStatus: http.StatusAccepted,
},
{
name: "Admin Password Update low entropy should fail",
setupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
updateFunc: func(u *models.User) {
u.Password = "newpassword"
},
expectedErrors: []map[string]string{
{"field": "server.validation.special server.validation.uppercase server.validation.numbers server.validation.longer", "key": "server.validation.insecure"},
},
expectedStatus: http.StatusBadRequest,
},
{
name: "Admin Password Update",
setupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
updateFunc: func(u *models.User) {
u.LastName = "Doe Updated" u.LastName = "Doe Updated"
u.Phone = "01738484994" u.Phone = "01738484994"
u.Licence.Number = "B072RRE2I50" u.Licence.Number = "B072RRE2I50"
@@ -602,19 +716,21 @@ func testUpdateUser(t *testing.T, loginCookie http.Cookie) {
}, },
expectedStatus: http.StatusAccepted, expectedStatus: http.StatusAccepted,
}, },
// { {
// name: "Non-existent User", name: "Non-existent User",
// setupCookie: func(req *http.Request) { setupCookie: func(req *http.Request) {
// req.AddCookie(&loginCookie) req.AddCookie(MemberCookie)
// }, },
// updateFunc: func(u *models.User) { updateFunc: func(u *models.User) {
// u.Password = "" u.Password = ""
// u.ID = 99999 u.ID = 99999
// u.FirstName = "Non-existent" u.FirstName = "Non-existent"
// }, },
// expectedStatus: http.StatusNotFound, expectedErrors: []map[string]string{
// expectedError: "User not found", {"field": "user.user", "key": "server.error.unauthorized"},
// }, },
expectedStatus: http.StatusForbidden,
},
} }
for _, tt := range tests { for _, tt := range tests {
logger.Error.Print("==============================================================") logger.Error.Print("==============================================================")
@@ -622,10 +738,9 @@ func testUpdateUser(t *testing.T, loginCookie http.Cookie) {
logger.Error.Print("==============================================================") logger.Error.Print("==============================================================")
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Create a copy of the user and apply the updates // Create a copy of the user and apply the updates
updatedUser := user updatedUser := *user
logger.Error.Printf("user to be updated: %+v", user.Licence) // logger.Error.Printf("users licence to be updated: %+v", user.Licence)
tt.updateFunc(&updatedUser) tt.updateFunc(&updatedUser)
// Convert user to JSON
updateData := &RegistrationData{User: updatedUser} updateData := &RegistrationData{User: updatedUser}
jsonData, err := json.Marshal(updateData) jsonData, err := json.Marshal(updateData)
@@ -633,7 +748,11 @@ func testUpdateUser(t *testing.T, loginCookie http.Cookie) {
t.Fatalf("Failed to marshal user data: %v", err) t.Fatalf("Failed to marshal user data: %v", err)
} }
// logger.Error.Printf("Updated User: %#v", updatedUser) logger.Error.Printf("Updated User: %#v", updatedUser.Safe())
if tt.expectedReturn != nil {
tt.expectedReturn(&updatedUser)
}
// Create request // Create request
req, _ := http.NewRequest("PUT", "/users/"+strconv.FormatUint(uint64(user.ID), 10), bytes.NewBuffer(jsonData)) req, _ := http.NewRequest("PUT", "/users/"+strconv.FormatUint(uint64(user.ID), 10), bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
@@ -684,13 +803,17 @@ func testUpdateUser(t *testing.T, loginCookie http.Cookie) {
assert.Equal(t, "User updated successfully", message) assert.Equal(t, "User updated successfully", message)
// Verify the update in the database // Verify the update in the database
updatedUserFromDB, err := Uc.Service.GetUserByID(user.ID) updatedUserFromDB, err := Uc.Service.FromID(&user.ID)
assert.NoError(t, err) assert.NoError(t, err)
if updatedUser.Password == "" { if updatedUser.Password == "" {
assert.Equal(t, user.Password, (*updatedUserFromDB).Password) assert.Equal(t, user.Password, (*updatedUserFromDB).Password)
} else { } else {
assert.NotEqual(t, user.Password, (*updatedUserFromDB).Password) matches, err := updatedUserFromDB.PasswordMatches(updatedUser.Password)
if err != nil {
t.Fatalf("Error matching password: %v", err)
}
assert.True(t, matches, "Password mismatch")
} }
updatedUserFromDB.Password = "" updatedUserFromDB.Password = ""
@@ -702,11 +825,9 @@ func testUpdateUser(t *testing.T, loginCookie http.Cookie) {
assert.Equal(t, updatedUser.Company, updatedUserFromDB.Company, "Company mismatch") assert.Equal(t, updatedUser.Company, updatedUserFromDB.Company, "Company mismatch")
assert.Equal(t, updatedUser.Phone, updatedUserFromDB.Phone, "Phone mismatch") assert.Equal(t, updatedUser.Phone, updatedUserFromDB.Phone, "Phone mismatch")
assert.Equal(t, updatedUser.Notes, updatedUserFromDB.Notes, "Notes mismatch") assert.Equal(t, updatedUser.Notes, updatedUserFromDB.Notes, "Notes mismatch")
assert.Equal(t, updatedUser.ProfilePicture, updatedUserFromDB.ProfilePicture, "ProfilePicture mismatch")
assert.Equal(t, updatedUser.Address, updatedUserFromDB.Address, "Address mismatch") assert.Equal(t, updatedUser.Address, updatedUserFromDB.Address, "Address mismatch")
assert.Equal(t, updatedUser.ZipCode, updatedUserFromDB.ZipCode, "ZipCode mismatch") assert.Equal(t, updatedUser.ZipCode, updatedUserFromDB.ZipCode, "ZipCode mismatch")
assert.Equal(t, updatedUser.City, updatedUserFromDB.City, "City mismatch") assert.Equal(t, updatedUser.City, updatedUserFromDB.City, "City mismatch")
assert.Equal(t, updatedUser.PaymentStatus, updatedUserFromDB.PaymentStatus, "PaymentStatus mismatch")
assert.Equal(t, updatedUser.Status, updatedUserFromDB.Status, "Status mismatch") assert.Equal(t, updatedUser.Status, updatedUserFromDB.Status, "Status mismatch")
assert.Equal(t, updatedUser.RoleID, updatedUserFromDB.RoleID, "RoleID mismatch") assert.Equal(t, updatedUser.RoleID, updatedUserFromDB.RoleID, "RoleID mismatch")
@@ -720,17 +841,32 @@ func testUpdateUser(t *testing.T, loginCookie http.Cookie) {
assert.Equal(t, updatedUser.Membership.StartDate, updatedUserFromDB.Membership.StartDate, "Membership.StartDate mismatch") assert.Equal(t, updatedUser.Membership.StartDate, updatedUserFromDB.Membership.StartDate, "Membership.StartDate mismatch")
assert.Equal(t, updatedUser.Membership.EndDate, updatedUserFromDB.Membership.EndDate, "Membership.EndDate mismatch") assert.Equal(t, updatedUser.Membership.EndDate, updatedUserFromDB.Membership.EndDate, "Membership.EndDate mismatch")
assert.Equal(t, updatedUser.Membership.Status, updatedUserFromDB.Membership.Status, "Membership.Status mismatch") assert.Equal(t, updatedUser.Membership.Status, updatedUserFromDB.Membership.Status, "Membership.Status mismatch")
assert.Equal(t, updatedUser.Membership.SubscriptionModelID, updatedUserFromDB.Membership.SubscriptionModelID, "Membership.SubscriptionModelID mismatch") assert.Equal(t, updatedUser.Membership.SubscriptionID, updatedUserFromDB.Membership.SubscriptionID, "Membership.SubscriptionID mismatch")
assert.Equal(t, updatedUser.Membership.ParentMembershipID, updatedUserFromDB.Membership.ParentMembershipID, "Membership.ParentMembershipID mismatch") assert.Equal(t, updatedUser.Membership.ParentMembershipID, updatedUserFromDB.Membership.ParentMembershipID, "Membership.ParentMembershipID mismatch")
if updatedUser.Licence == nil {
assert.Nil(t, updatedUserFromDB.Licence, "database licence of user is not nil, but user.licence is nil")
} else {
logger.Error.Printf("updatedUser licence: %#v", updatedUser.Licence)
logger.Error.Printf("dbUser licence: %#v", updatedUserFromDB.Licence)
assert.Equal(t, updatedUser.Licence.Status, updatedUserFromDB.Licence.Status, "Licence.Status mismatch") assert.Equal(t, updatedUser.Licence.Status, updatedUserFromDB.Licence.Status, "Licence.Status mismatch")
assert.Equal(t, updatedUser.Licence.Number, updatedUserFromDB.Licence.Number, "Licence.Number mismatch") assert.Equal(t, updatedUser.Licence.Number, updatedUserFromDB.Licence.Number, "Licence.Number mismatch")
assert.Equal(t, updatedUser.Licence.IssuedDate, updatedUserFromDB.Licence.IssuedDate, "Licence.IssuedDate mismatch") assert.Equal(t, updatedUser.Licence.IssuedDate, updatedUserFromDB.Licence.IssuedDate, "Licence.IssuedDate mismatch")
assert.Equal(t, updatedUser.Licence.ExpirationDate, updatedUserFromDB.Licence.ExpirationDate, "Licence.ExpirationDate mismatch") assert.Equal(t, updatedUser.Licence.ExpirationDate, updatedUserFromDB.Licence.ExpirationDate, "Licence.ExpirationDate mismatch")
assert.Equal(t, updatedUser.Licence.IssuingCountry, updatedUserFromDB.Licence.IssuingCountry, "Licence.IssuingCountry mismatch") assert.Equal(t, updatedUser.Licence.IssuingCountry, updatedUserFromDB.Licence.IssuingCountry, "Licence.IssuingCountry mismatch")
}
// For slices or more complex nested structures, you might want to use deep equality checks if len(updatedUser.Consents) > 0 {
assert.ElementsMatch(t, updatedUser.Consents, updatedUserFromDB.Consents, "Consents mismatch") for i := range updatedUser.Consents {
assert.Equal(t, updatedUser.Consents[i].ConsentType, updatedUserFromDB.Consents[i].ConsentType, "ConsentType mismatch at index %d", i)
assert.Equal(t, updatedUser.Consents[i].Email, updatedUserFromDB.Consents[i].Email, "ConsentEmail mismatch at index %d", i)
assert.Equal(t, updatedUser.Consents[i].FirstName, updatedUserFromDB.Consents[i].FirstName, "ConsentFirstName mismatch at index %d", i)
assert.Equal(t, updatedUser.Consents[i].LastName, updatedUserFromDB.Consents[i].LastName, "ConsentLastName mismatch at index %d", i)
assert.Equal(t, updatedUser.Consents[i].UserID, updatedUserFromDB.Consents[i].UserID, "Consent UserId mismatch at index %d", i)
}
} else {
assert.Emptyf(t, updatedUserFromDB.Licence.Categories, "Categories aren't empty when they should")
}
if len(updatedUser.Licence.Categories) > 0 { if len(updatedUser.Licence.Categories) > 0 {
for i := range updatedUser.Licence.Categories { for i := range updatedUser.Licence.Categories {
assert.Equal(t, updatedUser.Licence.Categories[i].Name, updatedUserFromDB.Licence.Categories[i].Name, "Category Category mismatch at index %d", i) assert.Equal(t, updatedUser.Licence.Categories[i].Name, updatedUserFromDB.Licence.Categories[i].Name, "Category Category mismatch at index %d", i)
@@ -756,11 +892,11 @@ func checkWelcomeMail(message *utils.Email, user *models.User) error {
if !strings.Contains(message.Body, user.FirstName) { if !strings.Contains(message.Body, user.FirstName) {
return fmt.Errorf("User first name(%v) has not been rendered in registration mail.", user.FirstName) return fmt.Errorf("User first name(%v) has not been rendered in registration mail.", user.FirstName)
} }
if !strings.Contains(message.Body, fmt.Sprintf("Preis/Monat</strong>: %v", user.Membership.SubscriptionModel.MonthlyFee)) { if !strings.Contains(message.Body, fmt.Sprintf("Preis/Monat</strong>: %v", user.Membership.Subscription.MonthlyFee)) {
return fmt.Errorf("Users monthly subscription fee(%v) has not been rendered in registration mail.", user.Membership.SubscriptionModel.MonthlyFee) return fmt.Errorf("Users monthly subscription fee(%v) has not been rendered in registration mail.", user.Membership.Subscription.MonthlyFee)
} }
if !strings.Contains(message.Body, fmt.Sprintf("Preis/h</strong>: %v", user.Membership.SubscriptionModel.HourlyRate)) { if !strings.Contains(message.Body, fmt.Sprintf("Preis/h</strong>: %v", user.Membership.Subscription.HourlyRate)) {
return fmt.Errorf("Users hourly subscription fee(%v) has not been rendered in registration mail.", user.Membership.SubscriptionModel.HourlyRate) return fmt.Errorf("Users hourly subscription fee(%v) has not been rendered in registration mail.", user.Membership.Subscription.HourlyRate)
} }
if user.Company != "" && !strings.Contains(message.Body, user.Company) { if user.Company != "" && !strings.Contains(message.Body, user.Company) {
return fmt.Errorf("Users Company(%v) has not been rendered in registration mail.", user.Company) return fmt.Errorf("Users Company(%v) has not been rendered in registration mail.", user.Company)
@@ -792,11 +928,11 @@ func checkRegistrationMail(message *utils.Email, user *models.User) error {
if !strings.Contains(message.Body, user.FirstName+" "+user.LastName) { if !strings.Contains(message.Body, user.FirstName+" "+user.LastName) {
return fmt.Errorf("User first and last name(%v) has not been rendered in registration mail.", user.FirstName+" "+user.LastName) return fmt.Errorf("User first and last name(%v) has not been rendered in registration mail.", user.FirstName+" "+user.LastName)
} }
if !strings.Contains(message.Body, fmt.Sprintf("Preis/Monat</strong>: %v", user.Membership.SubscriptionModel.MonthlyFee)) { if !strings.Contains(message.Body, fmt.Sprintf("Preis/Monat</strong>: %v", user.Membership.Subscription.MonthlyFee)) {
return fmt.Errorf("Users monthly subscription fee(%v) has not been rendered in registration mail.", user.Membership.SubscriptionModel.MonthlyFee) return fmt.Errorf("Users monthly subscription fee(%v) has not been rendered in registration mail.", user.Membership.Subscription.MonthlyFee)
} }
if !strings.Contains(message.Body, fmt.Sprintf("Preis/h</strong>: %v", user.Membership.SubscriptionModel.HourlyRate)) { if !strings.Contains(message.Body, fmt.Sprintf("Preis/h</strong>: %v", user.Membership.Subscription.HourlyRate)) {
return fmt.Errorf("Users hourly subscription fee(%v) has not been rendered in registration mail.", user.Membership.SubscriptionModel.HourlyRate) return fmt.Errorf("Users hourly subscription fee(%v) has not been rendered in registration mail.", user.Membership.Subscription.HourlyRate)
} }
if user.Company != "" && !strings.Contains(message.Body, user.Company) { if user.Company != "" && !strings.Contains(message.Body, user.Company) {
return fmt.Errorf("Users Company(%v) has not been rendered in registration mail.", user.Company) return fmt.Errorf("Users Company(%v) has not been rendered in registration mail.", user.Company)
@@ -836,15 +972,19 @@ func checkVerificationMail(message *utils.Email, user *models.User) error {
if err != nil { if err != nil {
return fmt.Errorf("Error parsing verification URL: %#v", err.Error()) return fmt.Errorf("Error parsing verification URL: %#v", err.Error())
} }
if !strings.Contains(verificationURL, user.Verification.VerificationToken) { v, err := user.FindVerification(constants.VerificationTypes.Email)
return fmt.Errorf("Users Verification link token(%v) has not been rendered in email verification mail. %v", user.Verification.VerificationToken, verificationURL) if err != nil {
return fmt.Errorf("Error getting verification token: %v", err.Error())
}
if !strings.Contains(verificationURL, v.VerificationToken) {
return fmt.Errorf("Users Verification link token(%v) has not been rendered in email verification mail. %v", v.VerificationToken, verificationURL)
} }
if !strings.Contains(message.Body, config.Site.BaseURL) { if !strings.Contains(message.Body, config.Site.BaseURL) {
return fmt.Errorf("Base Url (%v) has not been rendered in email verification mail.", config.Site.BaseURL) return fmt.Errorf("Base Url (%v) has not been rendered in email verification mail.", config.Site.BaseURL)
} }
// open the provided link: // open the provided link:
if err := verifyMail(verificationURL); err != nil { if err := verifyMail(verificationURL, user.ID); err != nil {
return err return err
} }
messages := utils.SMTPGetMessages() messages := utils.SMTPGetMessages()
@@ -861,12 +1001,14 @@ func checkVerificationMail(message *utils.Email, user *models.User) error {
} }
func verifyMail(verificationURL string) error { func verifyMail(verificationURL string, user_id uint) error {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
router := gin.New() router := gin.New()
router.LoadHTMLGlob(filepath.Join(config.Templates.HTMLPath, "*")) router.LoadHTMLGlob(filepath.Join(config.Templates.HTMLPath, "*"))
router.GET("/users/verify", Uc.VerifyMailHandler) expectedUrl := fmt.Sprintf("/api/users/verify/%v", user_id)
log.Printf("Expected URL: %v", expectedUrl)
router.GET("/api/users/verify/:id", Uc.VerifyMailHandler)
wv := httptest.NewRecorder() wv := httptest.NewRecorder()
cv, _ := gin.CreateTestContext(wv) cv, _ := gin.CreateTestContext(wv)
var err error var err error
@@ -987,6 +1129,7 @@ func getTestUsers() []RegisterUserTest {
Assert: false, Assert: false,
Input: GenerateInputJSON(customizeInput(func(user models.User) models.User { Input: GenerateInputJSON(customizeInput(func(user models.User) models.User {
user.BankAccount.IBAN = "" user.BankAccount.IBAN = ""
user.RoleID = 1
return user return user
})), })),
}, },
@@ -997,6 +1140,33 @@ func getTestUsers() []RegisterUserTest {
Assert: false, Assert: false,
Input: GenerateInputJSON(customizeInput(func(user models.User) models.User { Input: GenerateInputJSON(customizeInput(func(user models.User) models.User {
user.BankAccount.IBAN = "DE1234234123134" user.BankAccount.IBAN = "DE1234234123134"
user.RoleID = 1
return user
})),
},
{
Name: "invalid IBAN should fail when supporter",
WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"email": "john.supporter@example.com"},
Assert: false,
Input: GenerateInputJSON(customizeInput(func(user models.User) models.User {
user.BankAccount.IBAN = "DE1234234123134"
user.RoleID = constants.Roles.Supporter
user.Email = "john.supporter@example.com"
user.Membership.Subscription.Name = constants.SupporterSubscriptionName
return user
})),
},
{
Name: "empty IBAN should pass when supporter",
WantResponse: http.StatusCreated,
WantDBData: map[string]interface{}{"email": "john.supporter@example.com"},
Assert: true,
Input: GenerateInputJSON(customizeInput(func(user models.User) models.User {
user.BankAccount.IBAN = ""
user.RoleID = constants.Roles.Supporter
user.Email = "john.supporter@example.com"
user.Membership.Subscription.Name = constants.SupporterSubscriptionName
return user return user
})), })),
}, },
@@ -1006,7 +1176,7 @@ func getTestUsers() []RegisterUserTest {
WantDBData: map[string]interface{}{"email": "john.doe@example.com"}, WantDBData: map[string]interface{}{"email": "john.doe@example.com"},
Assert: false, Assert: false,
Input: GenerateInputJSON(customizeInput(func(user models.User) models.User { Input: GenerateInputJSON(customizeInput(func(user models.User) models.User {
user.Membership.SubscriptionModel.Name = "" user.Membership.Subscription.Name = ""
return user return user
})), })),
}, },
@@ -1016,7 +1186,7 @@ func getTestUsers() []RegisterUserTest {
WantDBData: map[string]interface{}{"email": "john.doe@example.com"}, WantDBData: map[string]interface{}{"email": "john.doe@example.com"},
Assert: false, Assert: false,
Input: GenerateInputJSON(customizeInput(func(user models.User) models.User { Input: GenerateInputJSON(customizeInput(func(user models.User) models.User {
user.Membership.SubscriptionModel.Name = "NOTEXISTENTPLAN" user.Membership.Subscription.Name = "NOTEXISTENTPLAN"
return user return user
})), })),
}, },
@@ -1055,7 +1225,7 @@ func getTestUsers() []RegisterUserTest {
Assert: false, Assert: false,
Input: GenerateInputJSON(customizeInput(func(user models.User) models.User { Input: GenerateInputJSON(customizeInput(func(user models.User) models.User {
user.Email = "john.junior.doe@example.com" user.Email = "john.junior.doe@example.com"
user.Membership.SubscriptionModel.Name = "additional" user.Membership.Subscription.Name = "additional"
return user return user
})), })),
}, },
@@ -1067,7 +1237,7 @@ func getTestUsers() []RegisterUserTest {
Input: GenerateInputJSON(customizeInput(func(user models.User) models.User { Input: GenerateInputJSON(customizeInput(func(user models.User) models.User {
user.Email = "john.junior.doe@example.com" user.Email = "john.junior.doe@example.com"
user.Membership.ParentMembershipID = 200 user.Membership.ParentMembershipID = 200
user.Membership.SubscriptionModel.Name = "additional" user.Membership.Subscription.Name = "additional"
return user return user
})), })),
}, },
@@ -1079,7 +1249,7 @@ func getTestUsers() []RegisterUserTest {
Input: GenerateInputJSON(customizeInput(func(user models.User) models.User { Input: GenerateInputJSON(customizeInput(func(user models.User) models.User {
user.Email = "john.junior.doe@example.com" user.Email = "john.junior.doe@example.com"
user.Membership.ParentMembershipID = 1 user.Membership.ParentMembershipID = 1
user.Membership.SubscriptionModel.Name = "additional" user.Membership.Subscription.Name = "additional"
return user return user
})), })),
}, },
@@ -1128,7 +1298,7 @@ func getTestUsers() []RegisterUserTest {
{ {
Name: "Correct Licence number, should pass", Name: "Correct Licence number, should pass",
WantResponse: http.StatusCreated, WantResponse: http.StatusCreated,
WantDBData: map[string]interface{}{"email": "john.correctLicenceNumber@example.com"}, WantDBData: map[string]interface{}{"email": "john.correctlicencenumber@example.com"},
Assert: true, Assert: true,
Input: GenerateInputJSON(customizeInput(func(user models.User) models.User { Input: GenerateInputJSON(customizeInput(func(user models.User) models.User {
user.Email = "john.correctLicenceNumber@example.com" user.Email = "john.correctLicenceNumber@example.com"

View File

@@ -0,0 +1,200 @@
package database
import (
"GoMembership/internal/constants"
"GoMembership/internal/models"
"GoMembership/pkg/logger"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var DB *gorm.DB
func Open(dbPath string, adminMail string, debug bool) (*gorm.DB, error) {
// Add foreign key support and WAL journal mode to DSN
dsn := fmt.Sprintf("%s?_foreign_keys=1&_journal_mode=WAL", dbPath)
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{
// Enable PrepareStmt for better performance
PrepareStmt: true,
})
if err != nil {
return nil, fmt.Errorf("failed to connect database: %w", err)
}
// Verify foreign key support is enabled
var foreignKeyEnabled int
if err := db.Raw("PRAGMA foreign_keys").Scan(&foreignKeyEnabled).Error; err != nil {
return nil, fmt.Errorf("foreign key check failed: %w", err)
}
if foreignKeyEnabled != 1 {
return nil, errors.New("SQLite foreign key constraints not enabled")
}
if debug {
db = db.Debug()
}
// Configure connection pool
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("failed to get DB instance: %w", err)
}
sqlDB.SetMaxOpenConns(1) // Required for SQLite in production
sqlDB.SetMaxIdleConns(1)
sqlDB.SetConnMaxLifetime(time.Hour)
db.Exec("PRAGMA foreign_keys = OFF;")
if err := db.AutoMigrate(
&models.Subscription{},
&models.Membership{},
&models.Consent{},
&models.Verification{},
&models.BankAccount{},
&models.Licence{},
&models.Category{},
&models.Car{},
&models.Location{},
&models.Damage{},
// &models.Insurance{},
// &models.User{},
); err != nil {
return nil, fmt.Errorf("failed to migrate database: %w", err)
}
db.Exec("PRAGMA foreign_keys = ON;")
logger.Info.Print("Opened DB")
DB = db
var categoriesCount int64
db.Model(&models.Category{}).Count(&categoriesCount)
if categoriesCount == 0 {
categories := createLicenceCategories()
for _, model := range categories {
result := db.Create(&model)
if result.Error != nil {
return nil, result.Error
}
}
}
var subscriptionsCount int64
db.Model(&models.Subscription{}).Count(&subscriptionsCount)
subscriptions := createSubscriptions()
for _, model := range subscriptions {
var exists int64
db.
Model(&models.Subscription{}).
Where("name = ?", model.Name).
Count(&exists)
logger.Error.Printf("looked for model.name %v and found %v", model.Name, exists)
if exists == 0 {
result := db.Create(&model)
if result.Error != nil {
return nil, result.Error
}
}
}
var userCount int64
db.Model(&models.User{}).Count(&userCount)
if userCount == 0 {
var createdModel models.Subscription
if err := db.First(&createdModel).Error; err != nil {
return nil, err
}
admin, err := createAdmin(adminMail)
if err != nil {
return nil, err
}
admin.Create(db)
}
return db, nil
}
func createSubscriptions() []models.Subscription {
return []models.Subscription{
{
Name: constants.SupporterSubscriptionName,
Details: "Dieses Modell ist für Sponsoren und Nichtmitglieder, die keinen Vereinsmitglied sind.",
HourlyRate: 999,
MonthlyFee: 0,
},
}
}
func createLicenceCategories() []models.Category {
return []models.Category{
{Name: "AM"},
{Name: "A1"},
{Name: "A2"},
{Name: "A"},
{Name: "B"},
{Name: "C1"},
{Name: "C"},
{Name: "D1"},
{Name: "D"},
{Name: "BE"},
{Name: "C1E"},
{Name: "CE"},
{Name: "D1E"},
{Name: "DE"},
{Name: "T"},
{Name: "L"},
}
}
// TODO: Landing page to create an admin
func createAdmin(userMail string) (*models.User, error) {
passwordBytes := make([]byte, 12)
_, err := rand.Read(passwordBytes)
if err != nil {
return nil, err
}
// Encode into a URL-safe base64 string
password := base64.URLEncoding.EncodeToString(passwordBytes)[:12]
logger.Error.Print("==============================================================")
logger.Error.Printf("Admin Email: %v", userMail)
logger.Error.Printf("Admin Password: %v", password)
logger.Error.Print("==============================================================")
return &models.User{
FirstName: "Ad",
LastName: "Min",
DateOfBirth: time.Now().AddDate(-20, 0, 0),
Password: password,
Company: "",
Address: "",
ZipCode: "",
City: "",
Phone: "",
Notes: "",
Email: userMail,
Status: constants.ActiveStatus,
RoleID: constants.Roles.Admin,
Consents: nil,
Verifications: nil,
Membership: nil,
BankAccount: nil,
Licence: nil,
}, nil
//"DE49700500000008447644", //fake
}
func Close(db *gorm.DB) error {
logger.Info.Print("Closing DB")
database, err := db.DB()
if err != nil {
return err
}
return database.Close()
}

View File

@@ -2,9 +2,7 @@ package middlewares
import ( import (
"GoMembership/internal/config" "GoMembership/internal/config"
"GoMembership/internal/models"
"GoMembership/internal/utils" "GoMembership/internal/utils"
customerrors "GoMembership/pkg/errors"
"GoMembership/pkg/logger" "GoMembership/pkg/logger"
"errors" "errors"
"fmt" "fmt"
@@ -34,26 +32,43 @@ func verifyAndRenewToken(tokenString string) (string, uint, error) {
return "", 0, fmt.Errorf("Authorization token is required") return "", 0, fmt.Errorf("Authorization token is required")
} }
token, claims, err := ExtractContentFrom(tokenString) token, claims, err := ExtractContentFrom(tokenString)
if err != nil {
if err != nil && !errors.Is(err, jwt.ErrTokenExpired) {
logger.Error.Printf("Couldn't parse JWT token String: %v", err) logger.Error.Printf("Couldn't parse JWT token String: %v", err)
return "", 0, err return "", 0, err
} }
sessionID := (*claims)["session_id"].(string)
userID := uint((*claims)["user_id"].(float64)) if token.Valid {
roleID := int8((*claims)["role_id"].(float64)) // token is valid, so we can return the old tokenString
return tokenString, uint((*claims)["user_id"].(float64)), nil
}
// Token is expired but valid
sessionID, ok := (*claims)["session_id"].(string)
if !ok || sessionID == "" {
return "", 0, fmt.Errorf("invalid session ID")
}
id, ok := (*claims)["user_id"]
if !ok {
return "", 0, fmt.Errorf("missing user_id claim")
}
userID := uint(id.(float64))
id, ok = (*claims)["role_id"]
if !ok {
return "", 0, fmt.Errorf("missing role_id claim")
}
roleID := int8(id.(float64))
session, ok := sessions[sessionID] session, ok := sessions[sessionID]
if !ok { if !ok {
logger.Error.Printf("session not found") logger.Error.Printf("session not found")
return "", 0, fmt.Errorf("session not found") return "", 0, fmt.Errorf("session not found")
} }
if userID != session.UserID { if userID != session.UserID {
return "", 0, fmt.Errorf("Cookie has been altered, aborting..") return "", 0, fmt.Errorf("Cookie has been altered, aborting..")
} }
if token.Valid {
// token is valid, so we can return the old tokenString
return tokenString, session.UserID, customerrors.ErrValidToken
}
if time.Now().After(sessions[sessionID].ExpiresAt) { if time.Now().After(sessions[sessionID].ExpiresAt) {
delete(sessions, sessionID) delete(sessions, sessionID)
@@ -64,8 +79,8 @@ func verifyAndRenewToken(tokenString string) (string, uint, error) {
logger.Error.Printf("Session still valid generating new token") logger.Error.Printf("Session still valid generating new token")
// Session is still valid, generate a new token // Session is still valid, generate a new token
user := models.User{ID: userID, RoleID: roleID} user := map[string]interface{}{"user_id": userID, "role_id": roleID}
newTokenString, err := GenerateToken(config.Auth.JWTSecret, &user, sessionID) newTokenString, err := GenerateToken(&config.Auth.JWTSecret, user, sessionID)
if err != nil { if err != nil {
return "", 0, err return "", 0, err
} }
@@ -80,7 +95,7 @@ func AuthMiddleware() gin.HandlerFunc {
logger.Error.Printf("No Auth token: %v\n", err) logger.Error.Printf("No Auth token: %v\n", err)
c.JSON(http.StatusUnauthorized, c.JSON(http.StatusUnauthorized,
gin.H{"errors": []gin.H{{ gin.H{"errors": []gin.H{{
"field": "general", "field": "server.general",
"key": "server.error.no_auth_token", "key": "server.error.no_auth_token",
}}}) }}})
c.Abort() c.Abort()
@@ -89,39 +104,40 @@ func AuthMiddleware() gin.HandlerFunc {
newToken, userID, err := verifyAndRenewToken(tokenString) newToken, userID, err := verifyAndRenewToken(tokenString)
if err != nil { if err != nil {
if err == customerrors.ErrValidToken {
c.Set("user_id", uint(userID))
c.Next()
return
}
logger.Error.Printf("Token(%v) is invalid: %v\n", tokenString, err) logger.Error.Printf("Token(%v) is invalid: %v\n", tokenString, err)
c.JSON(http.StatusUnauthorized, c.JSON(http.StatusUnauthorized,
gin.H{"errors": []gin.H{{ gin.H{"errors": []gin.H{{
"field": "general", "field": "server.general",
"key": "server.error.no_auth_token", "key": "server.error.no_auth_token",
}}}) }}})
c.Abort() c.Abort()
return return
} }
if newToken != tokenString {
utils.SetCookie(c, newToken) utils.SetCookie(c, newToken)
}
c.Set("user_id", uint(userID)) c.Set("user_id", uint(userID))
c.Next() c.Next()
} }
} }
func GenerateToken(jwtKey string, user *models.User, sessionID string) (string, error) { // GenerateToken generates a new JWT token with the given claims and session ID.
// "user_id": user.ID, "role_id": user.RoleID
func GenerateToken(jwtKey *string, claims map[string]interface{}, sessionID string) (string, error) {
if sessionID == "" { if sessionID == "" {
sessionID = uuid.New().String() sessionID = uuid.New().String()
} }
token := jwt.NewWithClaims(jwtSigningMethod, jwt.MapClaims{ claims["session_id"] = sessionID
"user_id": user.ID, claims["exp"] = time.Now().Add(time.Minute * 1).Unix() // Token expires in 10 Minutes
"role_id": user.RoleID, token := jwt.NewWithClaims(jwtSigningMethod, jwt.MapClaims(claims))
"session_id": sessionID,
"exp": time.Now().Add(time.Minute * 1).Unix(), // Token expires in 10 Minutes userID, ok := claims["user_id"].(uint)
}) if !ok {
UpdateSession(sessionID, user.ID) return "", fmt.Errorf("invalid user_id in claims")
return token.SignedString([]byte(jwtKey)) }
UpdateSession(sessionID, userID)
return token.SignedString([]byte(*jwtKey))
} }
func ExtractContentFrom(tokenString string) (*jwt.Token, *jwt.MapClaims, error) { func ExtractContentFrom(tokenString string) (*jwt.Token, *jwt.MapClaims, error) {
@@ -130,23 +146,33 @@ func ExtractContentFrom(tokenString string) (*jwt.Token, *jwt.MapClaims, error)
return []byte(config.Auth.JWTSecret), nil return []byte(config.Auth.JWTSecret), nil
}) })
if !errors.Is(err, jwt.ErrTokenExpired) && err != nil { // Handle parsing errors (excluding expiration error)
logger.Error.Printf("Error during token(%v) parsing: %#v", tokenString, err) if err != nil && !errors.Is(err, jwt.ErrTokenExpired) {
logger.Error.Printf("Error parsing token: %v", err)
return nil, nil, err return nil, nil, err
} }
// Token is expired, check if session is still valid // Ensure token is not nil (e.g., malformed tokens)
claims, ok := token.Claims.(jwt.MapClaims) if token == nil {
if !ok { logger.Error.Print("Token is nil after parsing")
logger.Error.Printf("Invalid Token Claims") return nil, nil, fmt.Errorf("invalid token")
return nil, nil, fmt.Errorf("invalid token claims")
} }
// Extract and validate claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok { if !ok {
logger.Error.Printf("invalid session_id in token") logger.Error.Print("Invalid token claims structure")
return nil, nil, fmt.Errorf("invalid session_id in token") return nil, nil, fmt.Errorf("invalid token claims format")
} }
return token, &claims, nil
// Validate required session_id claim
if _, exists := claims["session_id"]; !exists {
logger.Error.Print("Missing session_id in token claims")
return nil, nil, fmt.Errorf("missing session_id claim")
}
// Return token, claims, and original error (might be expiration)
return token, &claims, err
} }
func UpdateSession(sessionID string, userID uint) { func UpdateSession(sessionID string, userID uint) {

View File

@@ -3,7 +3,6 @@ package middlewares
import ( import (
"GoMembership/internal/config" "GoMembership/internal/config"
"GoMembership/internal/constants" "GoMembership/internal/constants"
"GoMembership/internal/models"
"GoMembership/pkg/logger" "GoMembership/pkg/logger"
"encoding/json" "encoding/json"
"log" "log"
@@ -56,8 +55,11 @@ func TestAuthMiddleware(t *testing.T) {
{ {
name: "Valid Token", name: "Valid Token",
setupAuth: func(r *http.Request) { setupAuth: func(r *http.Request) {
user := models.User{ID: 123, RoleID: constants.Roles.Member} claims := map[string]interface{}{"user_id": uint(123), "role_id": constants.Roles.Member}
token, _ := GenerateToken(config.Auth.JWTSecret, &user, "") token, err := GenerateToken(&config.Auth.JWTSecret, claims, "")
if err != nil {
t.Fatal(err)
}
r.AddCookie(&http.Cookie{Name: "jwt", Value: token}) r.AddCookie(&http.Cookie{Name: "jwt", Value: token})
}, },
expectedStatus: http.StatusOK, expectedStatus: http.StatusOK,
@@ -82,7 +84,7 @@ func TestAuthMiddleware(t *testing.T) {
setupAuth: func(r *http.Request) { setupAuth: func(r *http.Request) {
sessionID := "test-session" sessionID := "test-session"
token := jwt.NewWithClaims(jwtSigningMethod, jwt.MapClaims{ token := jwt.NewWithClaims(jwtSigningMethod, jwt.MapClaims{
"user_id": 123, "user_id": uint(123),
"role_id": constants.Roles.Member, "role_id": constants.Roles.Member,
"session_id": sessionID, "session_id": sessionID,
"exp": time.Now().Add(-time.Hour).Unix(), // Expired 1 hour ago "exp": time.Now().Add(-time.Hour).Unix(), // Expired 1 hour ago
@@ -100,7 +102,7 @@ func TestAuthMiddleware(t *testing.T) {
setupAuth: func(r *http.Request) { setupAuth: func(r *http.Request) {
sessionID := "expired-session" sessionID := "expired-session"
token := jwt.NewWithClaims(jwtSigningMethod, jwt.MapClaims{ token := jwt.NewWithClaims(jwtSigningMethod, jwt.MapClaims{
"user_id": 123, "user_id": uint(123),
"role_id": constants.Roles.Member, "role_id": constants.Roles.Member,
"session_id": sessionID, "session_id": sessionID,
"exp": time.Now().Add(-time.Hour).Unix(), // Expired 1 hour ago "exp": time.Now().Add(-time.Hour).Unix(), // Expired 1 hour ago
@@ -116,7 +118,7 @@ func TestAuthMiddleware(t *testing.T) {
name: "Invalid Signature", name: "Invalid Signature",
setupAuth: func(r *http.Request) { setupAuth: func(r *http.Request) {
token := jwt.NewWithClaims(jwtSigningMethod, jwt.MapClaims{ token := jwt.NewWithClaims(jwtSigningMethod, jwt.MapClaims{
"user_id": 123, "user_id": uint(123),
"session_id": "some-session", "session_id": "some-session",
"exp": time.Now().Add(time.Hour).Unix(), "exp": time.Now().Add(time.Hour).Unix(),
}) })
@@ -130,7 +132,7 @@ func TestAuthMiddleware(t *testing.T) {
name: "Invalid Signing Method", name: "Invalid Signing Method",
setupAuth: func(r *http.Request) { setupAuth: func(r *http.Request) {
token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"user_id": 123, "user_id": uint(123),
"session_id": "some-session", "session_id": "some-session",
"role_id": constants.Roles.Member, "role_id": constants.Roles.Member,
"exp": time.Now().Add(time.Hour).Unix(), "exp": time.Now().Add(time.Hour).Unix(),

View File

@@ -0,0 +1,21 @@
package middlewares
import (
"GoMembership/internal/config"
"strings"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin"
)
func CORSMiddleware() gin.HandlerFunc {
return cors.New(cors.Config{
AllowOrigins: strings.Split(config.Site.AllowOrigins, ","),
AllowMethods: []string{"GET", "POST", "PATCH", "PUT", "OPTIONS"},
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization", "X-Requested-With", "X-CSRF-Token"},
ExposeHeaders: []string{"Content-Length"},
AllowCredentials: true,
AllowPrivateNetwork: true,
MaxAge: 12 * 60 * 60, // 12 hours
})
}

View File

@@ -69,10 +69,10 @@ func TestCORSMiddleware(t *testing.T) {
}{ }{
{ {
name: "Allowed origin", name: "Allowed origin",
origin: config.Site.AllowOrigins, origin: "http://localhost:8080",
expectedStatus: http.StatusOK, expectedStatus: http.StatusOK,
expectedHeaders: map[string]string{ expectedHeaders: map[string]string{
"Access-Control-Allow-Origin": config.Site.AllowOrigins, "Access-Control-Allow-Origin": "http://localhost:8080",
"Content-Type": "text/plain; charset=utf-8", "Content-Type": "text/plain; charset=utf-8",
"Access-Control-Allow-Credentials": "true", "Access-Control-Allow-Credentials": "true",
}, },

View File

@@ -34,7 +34,6 @@ func CSPMiddleware() gin.HandlerFunc {
func CSPReportHandling(c *gin.Context) { func CSPReportHandling(c *gin.Context) {
var report map[string]interface{} var report map[string]interface{}
if err := c.BindJSON(&report); err != nil { if err := c.BindJSON(&report); err != nil {
logger.Error.Printf("Couldn't Bind JSON: %#v", err) logger.Error.Printf("Couldn't Bind JSON: %#v", err)
return return
} }

View File

@@ -1,6 +1,8 @@
package middlewares package middlewares
import "github.com/gin-gonic/gin" import (
"github.com/gin-gonic/gin"
)
func SecurityHeadersMiddleware() gin.HandlerFunc { func SecurityHeadersMiddleware() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {

View File

@@ -0,0 +1,54 @@
package models
import (
"GoMembership/pkg/logger"
"time"
"gorm.io/gorm"
)
type Insurance struct {
ID uint `gorm:"primary_key" json:"id"`
Cars []Car `gorm:"many2many:car_insurances;" json:"-"`
Company string `json:"company" binding:"safe_content"`
Reference string `json:"reference" binding:"safe_content"`
Notes string `json:"notes" binding:"safe_content"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
}
func (i *Insurance) Create(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
// Create the base User record (omit associations to handle them separately)
if err := tx.Create(i).Error; err != nil {
return err
}
logger.Info.Printf("Insurance created: %#v", i)
// Preload all associations to return the fully populated User
return tx.
First(i, i.ID).Error // Refresh the user object with all associations
})
}
func (i *Insurance) Update(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
// Check if the user exists in the database
var existingInsurance Insurance
logger.Info.Printf("updating Insurance: %#v", i)
if err := tx.First(&existingInsurance, i.ID).Error; err != nil {
return err
}
if err := tx.Model(&existingInsurance).Updates(i).Error; err != nil {
return err
}
return tx.First(i, i.ID).Error
})
}
func (i *Insurance) Delete(db *gorm.DB) error {
return db.Delete(&i).Error
}

View File

@@ -0,0 +1,60 @@
package models
import (
"GoMembership/internal/config"
"GoMembership/pkg/logger"
"fmt"
"time"
"gorm.io/gorm"
)
type BankAccount struct {
ID uint `gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"-" binding:"-"`
UserID uint `gorm:"index" json:"user_id"`
MandateDateSigned time.Time `json:"mandate_date_signed"`
Bank string `json:"bank_name" binding:"safe_content"`
AccountHolderName string `json:"account_holder_name" binding:"safe_content"`
IBAN string `json:"iban" binding:"safe_content"`
BIC string `json:"bic" binding:"safe_content"`
MandateReference string `json:"mandate_reference" binding:"safe_content"`
}
func (b *BankAccount) Create(db *gorm.DB) error {
// b.ID = 0
// only the children the belongs to association gets a reference id
if err := db.Create(b).Error; err != nil {
return err
}
logger.Info.Printf("BankAccount created: %#v", b)
return db.First(b, b.ID).Error // Refresh the object with all associations
}
func (b *BankAccount) Update(db *gorm.DB) error {
var existingBankAccount BankAccount
logger.Info.Printf("updating BankAccount: %#v", b)
if err := db.First(&existingBankAccount, b.ID).Error; err != nil {
return err
}
if err := db.Model(&existingBankAccount).Updates(b).Error; err != nil {
return err
}
return db.First(b, b.ID).Error
}
func (b *BankAccount) Delete(db *gorm.DB) error {
return db.Delete(&b).Error
}
func (b *BankAccount) GenerateMandateReference(id uint) string {
if b.IBAN == "" {
return ""
}
return fmt.Sprintf("%s-%s%d-%s", config.Company.SepaPrefix, time.Now().Format("20060102"), id, b.IBAN[len(b.IBAN)-4:])
}

View File

@@ -0,0 +1,165 @@
package models
import (
"GoMembership/pkg/logger"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Car struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
Status uint `json:"status"`
Name string `json:"name"`
Brand string `gorm:"not null" json:"brand"`
Model string `gorm:"not null" json:"model"`
Color string `gorm:"not null" json:"color"`
LicencePlate string `gorm:"not null,unique" json:"licence_plate"`
Price float32 `gorm:"type:decimal(10,2)" json:"price"`
Rate float32 `gorm:"type:decimal(10,2)" json:"rate"`
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
Location *Location `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"location"`
Damages []Damage `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"damages"`
Insurances []Insurance `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;many2many:car_insurances" json:"insurances"`
Notes string `json:"notes"`
}
func (c *Car) Create(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Preload(clause.Associations).Create(c).Error; err != nil {
return err
}
logger.Info.Printf("car created: %#v", c)
return tx.
Preload(clause.Associations).
First(c, c.ID).Error
})
}
func (c *Car) Update(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
// Check if the user exists in the database
var existingCar Car
logger.Info.Printf("updating car: %#v", c)
if err := tx.Preload("Damages.Insurance").
Preload("Damages.Opponent").
First(&existingCar, c.ID).Error; err != nil {
return err
}
if err := tx.Session(&gorm.Session{FullSaveAssociations: true}).Updates(c).Error; err != nil {
return err
}
// if err := tx.Model(c).Association("Damages").Replace(c.Damages); err != nil {
// return err
// }
// if err := tx.Model(c).Association("Insurances").Replace(c.Insurances); err != nil {
// return err
// }
// Calculate damage IDs to delete
// existingDamageIDs := make(map[uint]bool)
// for _, d := range existingCar.Damages {
// existingDamageIDs[d.ID] = true
// }
// newDamageIDs := make(map[uint]bool)
// for _, d := range c.Damages {
// if d.ID != 0 {
// newDamageIDs[d.ID] = true
// }
// }
// // Find IDs to delete
// var toDelete []uint
// for id := range existingDamageIDs {
// if !newDamageIDs[id] {
// toDelete = append(toDelete, id)
// }
// }
// // Batch delete orphaned damages
// if len(toDelete) > 0 {
// if err := tx.Where("id IN ?", toDelete).Delete(&Damage{}).Error; err != nil {
// return err
// }
// }
// if len(c.Insurances) > 0 {
// logger.Info.Printf("updating insurances: %#v", c.Insurances)
// if err := tx.Model(&existingCar).Association("Insurances").Replace(c.Insurances); err != nil {
// return err
// }
// }
// // Upsert new damages
// for _, damage := range c.Damages {
// // Process relationships
// if damage.Opponent != nil {
// if err := tx.Save(damage.Opponent).Error; err != nil {
// return err
// }
// damage.OpponentID = damage.Opponent.ID
// }
// if damage.Insurance != nil {
// if err := tx.Save(damage.Insurance).Error; err != nil {
// return err
// }
// damage.InsuranceID = damage.Insurance.ID
// }
// // Create or update damage
// if err := tx.Save(damage).Error; err != nil {
// return err
// }
// }
// // Update associations
// if err := tx.Model(&existingCar).Association("Damages").Replace(c.Damages); err != nil {
// return err
// }
return tx.
Preload(clause.Associations).
Preload("Damages").
Preload("Insurances").
First(c, c.ID).Error
})
}
func (c *Car) Delete(db *gorm.DB) error {
return db.Select(clause.Associations).Delete(&c).Error
}
func GetAllCars(db *gorm.DB) ([]Car, error) {
var cars []Car
if err := db.
Preload(clause.Associations).
Preload("Damages").
Preload("Insurances").
Find(&cars).Error; err != nil {
return nil, err
}
return cars, nil
}
func (c *Car) FromID(db *gorm.DB, id uint) error {
var car Car
if err := db.
Preload(clause.Associations).
Preload("Damages").
Preload("Insurances").
First(&car, id).Error; err != nil {
return err
}
*c = car
return nil
}

View File

@@ -0,0 +1,48 @@
package models
import (
"GoMembership/pkg/logger"
"gorm.io/gorm"
)
type Category struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"category" binding:"safe_content"`
}
func (c *Category) Create(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
// Create the base User record (omit associations to handle them separately)
if err := tx.Create(c).Error; err != nil {
return err
}
logger.Info.Printf("Category created: %#v", c)
// Preload all associations to return the fully populated User
return tx.
First(c, c.ID).Error // Refresh the user object with all associations
})
}
func (c *Category) Update(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
// Check if the user exists in the database
var existingCategory Category
logger.Info.Printf("updating Category: %#v", c)
if err := tx.First(&existingCategory, c.ID).Error; err != nil {
return err
}
if err := tx.Model(&existingCategory).Updates(c).Error; err != nil {
return err
}
return tx.First(c, c.ID).Error
})
}
func (c *Category) Delete(db *gorm.DB) error {
return db.Delete(&c).Error
}

View File

@@ -0,0 +1,56 @@
package models
import (
"GoMembership/pkg/logger"
"strings"
"time"
"gorm.io/gorm"
)
type Consent struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time
UpdatedAt time.Time
FirstName string `gorm:"not null" json:"first_name" binding:"safe_content"`
LastName string `gorm:"not null" json:"last_name" binding:"safe_content"`
Email string `json:"email" binding:"email,safe_content"`
ConsentType string `gorm:"not null" json:"consent_type" binding:"safe_content"`
UserID *uint `json:"user_id"`
User *User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"-" binding:"-"`
}
func (c *Consent) BeforeSave(tx *gorm.DB) (err error) {
c.Email = strings.ToLower(c.Email)
return nil
}
func (c *Consent) Create(db *gorm.DB) error {
if err := db.Create(c).Error; err != nil {
return err
}
logger.Info.Printf("Consent created: %#v", c)
return db.First(c, c.ID).Error // Refresh the user object with all associations
}
func (c *Consent) Update(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
// Check if the user exists in the database
var existingConsent Consent
logger.Info.Printf("updating Consent: %#v", c)
if err := tx.First(&existingConsent, c.ID).Error; err != nil {
return err
}
if err := tx.Model(&existingConsent).Updates(c).Error; err != nil {
return err
}
return tx.First(c, c.ID).Error
})
}
func (c *Consent) Delete(db *gorm.DB) error {
return db.Delete(&c).Error
}

View File

@@ -0,0 +1,58 @@
package models
import (
"GoMembership/pkg/logger"
"time"
"gorm.io/gorm"
)
type Damage struct {
ID uint `gorm:"primaryKey" json:"id"`
CarID uint `json:"car_id"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
Name string `json:"name"`
Date time.Time `json:"date"`
Opponent *User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"opponent"`
OpponentID uint `json:"opponent_id"`
Driver *User `json:"driver"`
DriverID uint `json:"driver_id"`
Insurance *Insurance `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"insurance"`
InsuranceID uint `json:"insurance_id"`
Notes string `json:"notes"`
}
func (d *Damage) Create(db *gorm.DB) error {
// Create the base User record (omit associations to handle them separately)
if err := db.Create(d).Error; err != nil {
return err
}
logger.Info.Printf("Damage created: %#v", d)
// Preload all associations to return the fully populated User
return db.First(d, d.ID).Error // Refresh the user object with all associations
}
func (d *Damage) Update(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
// Check if the user exists in the database
var existingDamage Damage
logger.Info.Printf("updating Damage: %#v", d)
if err := tx.First(&existingDamage, d.ID).Error; err != nil {
return err
}
if err := tx.Model(&existingDamage).Updates(d).Error; err != nil {
return err
}
return tx.First(d, d.ID).Error
})
}
func (d *Damage) Delete(db *gorm.DB) error {
return db.Delete(&d).Error
}

View File

@@ -0,0 +1,66 @@
package models
import (
"GoMembership/pkg/logger"
"fmt"
"time"
"gorm.io/gorm"
)
type Licence struct {
ID uint `json:"id"`
UserID uint `json:"user_id"`
CreatedAt time.Time
UpdatedAt time.Time
Status int8 `json:"status" binding:"omitempty,number"`
Number string `json:"number" binding:"omitempty,safe_content"`
IssuedDate time.Time `json:"issued_date" binding:"omitempty"`
ExpirationDate time.Time `json:"expiration_date" binding:"omitempty"`
IssuingCountry string `json:"country" binding:"safe_content"`
Categories []*Category `json:"categories" gorm:"many2many:licence_2_categories"`
}
func (l *Licence) BeforeSafe(tx *gorm.DB) error {
if err := tx.Model(l).Association("Categories").Replace(l.Categories); err != nil {
return fmt.Errorf("failed to link categories: %w", err)
}
return nil
}
func (l *Licence) Create(db *gorm.DB) error {
if err := db.Omit("Categories").Create(l).Error; err != nil {
return err
}
if err := db.Model(&l).Association("Categories").Replace(l.Categories); err != nil {
return err
}
logger.Info.Printf("Licence created: %#v", l)
return db.Preload("Categories").First(l, l.ID).Error // Refresh the object with Categories
}
func (l *Licence) Update(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
// Check if the user exists in the database
var existingLicence Licence
logger.Info.Printf("updating Licence: %#v", l)
if err := tx.First(&existingLicence, l.ID).Error; err != nil {
return err
}
if err := tx.Model(&existingLicence).Updates(l).Error; err != nil {
return err
}
return tx.First(l, l.ID).Error
})
}
func (l *Licence) Delete(db *gorm.DB) error {
return db.Delete(&l).Error
}

View File

@@ -0,0 +1,54 @@
package models
import (
"GoMembership/pkg/logger"
"time"
"gorm.io/gorm"
)
type Location struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
CarID uint `gorm:"index" json:"car_id"`
Latitude float32 `json:"latitude"`
Longitude float32 `json:"longitude"`
}
func (l *Location) Create(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
// Create the base User record (omit associations to handle them separately)
if err := tx.Create(l).Error; err != nil {
return err
}
logger.Info.Printf("Location created: %#v", l)
// Preload all associations to return the fully populated User
return tx.
First(l, l.ID).Error // Refresh the user object with all associations
})
}
func (l *Location) Update(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
// Check if the user exists in the database
var existingLocation Location
logger.Info.Printf("updating Location: %#v", l)
if err := tx.First(&existingLocation, l.ID).Error; err != nil {
return err
}
if err := tx.Model(&existingLocation).Updates(l).Error; err != nil {
return err
}
return tx.First(l, l.ID).Error
})
}
func (l *Location) Delete(db *gorm.DB) error {
return db.Delete(&l).Error
}

View File

@@ -0,0 +1,59 @@
package models
import (
"GoMembership/pkg/logger"
"time"
"gorm.io/gorm"
)
type Membership struct {
ID uint `gorm:"primaryKey" json:"id"`
UserID uint `gorm:"index" json:"user_id"`
User User `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"-" binding:"-"`
CreatedAt time.Time
UpdatedAt time.Time
StartDate time.Time `json:"start_date"`
EndDate time.Time `json:"end_date"`
Status int8 `json:"status" binding:"number,safe_content"`
Subscription Subscription `gorm:"foreignKey:SubscriptionID" json:"subscription"`
SubscriptionID uint `json:"subscription_id"`
ParentMembershipID uint `json:"parent_member_id" binding:"omitempty,omitnil,number"`
}
func (m *Membership) BeforeSave(tx *gorm.DB) error {
m.SubscriptionID = m.Subscription.ID
return nil
}
func (m *Membership) Create(db *gorm.DB) error {
if err := db.Create(m).Error; err != nil {
return err
}
logger.Info.Printf("Membership created: %#v", m)
return db.Preload("Subscription").First(m, m.ID).Error // Refresh the user object with Subscription
}
func (m *Membership) Update(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
// Check if the user exists in the database
var existingMembership Membership
logger.Info.Printf("updating Membership: %#v", m)
if err := tx.First(&existingMembership, m.ID).Error; err != nil {
return err
}
if err := tx.Model(&existingMembership).Updates(m).Error; err != nil {
return err
}
return tx.First(m, m.ID).Error
})
}
func (m *Membership) Delete(db *gorm.DB) error {
return db.Delete(&m).Error
}

View File

@@ -0,0 +1,58 @@
package models
import (
"GoMembership/pkg/logger"
"time"
"gorm.io/gorm"
)
type Subscription struct {
ID uint `json:"id" gorm:"primaryKey"`
CreatedAt time.Time
UpdatedAt time.Time
Name string `gorm:"uniqueIndex:idx_subscriptions_name" json:"name" binding:"required,safe_content"`
Details string `json:"details" binding:"safe_content"`
Conditions string `json:"conditions" binding:"safe_content"`
RequiredMembershipField string `json:"required_membership_field" binding:"safe_content"`
MonthlyFee float32 `json:"monthly_fee"`
HourlyRate float32 `json:"hourly_rate"`
IncludedPerYear int16 `json:"included_hours_per_year"`
IncludedPerMonth int16 `json:"included_hours_per_month"`
}
func (s *Subscription) Create(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
// Create the base User record (omit associations to handle them separately)
if err := tx.Create(s).Error; err != nil {
return err
}
logger.Info.Printf("Subscription created: %#v", s)
// Preload all associations to retuvn the fully populated User
return tx.
First(s, s.ID).Error // Refresh the user object with all associations
})
}
func (s *Subscription) Update(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
// Check if the user exists in the database
var existingSubscription Subscription
logger.Info.Printf("updating Subscription: %#v", s)
if err := tx.First(&existingSubscription, s.ID).Error; err != nil {
return err
}
if err := tx.Model(&existingSubscription).Updates(s).Error; err != nil {
return err
}
return tx.First(s, s.ID).Error
})
}
func (s *Subscription) Delete(db *gorm.DB) error {
return db.Delete(&s).Error
}

View File

@@ -0,0 +1,371 @@
package models
import (
"GoMembership/internal/config"
"GoMembership/internal/constants"
"GoMembership/pkg/errors"
"GoMembership/pkg/logger"
"fmt"
"slices"
"strings"
"time"
"github.com/alexedwards/argon2id"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type User struct {
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
DateOfBirth time.Time `gorm:"not null" json:"dateofbirth" binding:"required_unless=RoleID 0,safe_content"`
Company string `json:"company" binding:"omitempty,omitnil,safe_content"`
Phone string `json:"phone" binding:"omitempty,omitnil,safe_content"`
Notes string `json:"notes" binding:"safe_content"`
FirstName string `gorm:"not null" json:"first_name" binding:"required,safe_content"`
Password string `json:"password" binding:"safe_content"`
Email string `gorm:"uniqueIndex:idx_users_email,not null" json:"email" binding:"required,email,safe_content"`
LastName string `gorm:"not null" json:"last_name" binding:"required,safe_content"`
Address string `gorm:"not null" json:"address" binding:"required,safe_content"`
ZipCode string `gorm:"not null" json:"zip_code" binding:"required,alphanum,safe_content"`
City string `form:"not null" json:"city" binding:"required,alphaunicode,safe_content"`
Consents []Consent `gorm:"foreignKey:UserID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL"`
BankAccount *BankAccount `gorm:"foreignkey:UserID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"bank_account"`
Verifications []Verification `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Membership *Membership `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"membership"`
Licence *Licence `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE" json:"licence"`
Status int8 `json:"status"`
RoleID int8 `json:"role_id"`
}
func (u *User) AfterCreate(tx *gorm.DB) (err error) {
if u.BankAccount != nil && u.BankAccount.MandateReference == "" {
u.BankAccount.MandateReference = u.BankAccount.GenerateMandateReference(u.ID)
u.BankAccount.Update(tx)
}
return nil
}
func (u *User) BeforeSave(tx *gorm.DB) (err error) {
u.Email = strings.ToLower(u.Email)
if u.Password != "" {
hash, err := argon2id.CreateHash(u.Password, argon2id.DefaultParams)
if err != nil {
return err
}
u.Password = hash
}
return nil
}
func (u *User) Create(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Preload(clause.Associations).Create(u).Error; err != nil {
return err
}
return nil
})
}
func (u *User) Update(db *gorm.DB) error {
err := db.Transaction(func(tx *gorm.DB) error {
// Check if the user exists in the database
var existingUser User
logger.Info.Printf("updating user: %#v", u)
if err := tx.
First(&existingUser, u.ID).Error; err != nil {
return err
}
// Update the user's main fields
result := tx.Session(&gorm.Session{FullSaveAssociations: true}).Omit("Verifications", "Licence.Categories").Updates(u)
if result.Error != nil {
logger.Error.Printf("User update error in update user: %#v", result.Error)
return result.Error
}
if result.RowsAffected == 0 {
return errors.ErrNoRowsAffected
}
if u.Verifications != nil {
if err := tx.Save(u.Verifications).Error; err != nil {
return err
}
}
if u.Licence != nil {
if err := tx.Model(u.Licence).Association("Categories").Replace(u.Licence.Categories); err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
return db.
Preload(clause.Associations).
Preload("Membership.Subscription").
Preload("Licence.Categories").
First(&u, u.ID).Error
}
func (u *User) Delete(db *gorm.DB) error {
return db.Unscoped().Delete(&User{}, "id = ?", u.ID).Error
// return db.Delete(&User{}, "id = ?", u.ID).Error
}
func (u *User) FromID(db *gorm.DB, userID *uint) error {
var user User
result := db.
Preload(clause.Associations).
Preload("Membership.Subscription").
Preload("Licence.Categories").
First(&user, userID)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return gorm.ErrRecordNotFound
}
return result.Error
}
*u = user
return nil
}
func (u *User) FromEmail(db *gorm.DB, email *string) error {
var user User
result := db.
Preload(clause.Associations).
Preload("Membership.Subscription").
Preload("Licence.Categories").
Where("email = ?", email).First(&user)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return gorm.ErrRecordNotFound
}
return result.Error
}
*u = user
return nil
}
func (u *User) FromContext(db *gorm.DB, c *gin.Context) error {
tokenString, err := c.Cookie("jwt")
if err != nil {
return err
}
jwtUserID, err := extractUserIDFrom(tokenString)
if err != nil {
return err
}
if err = u.FromID(db, &jwtUserID); err != nil {
return err
}
return nil
}
func (u *User) PasswordMatches(plaintextPassword string) (bool, error) {
return argon2id.ComparePasswordAndHash(plaintextPassword, u.Password)
}
func (u *User) PasswordExists() bool {
return u.Password != ""
}
func (u *User) IsVerified() bool {
return u.Status > constants.DisabledStatus
}
func (u *User) HasPrivilege(privilege int8) bool {
return u.RoleID >= privilege
}
func (u *User) IsAdmin() bool {
return u.RoleID == constants.Roles.Admin
}
func (u *User) IsMember() bool {
return u.RoleID == constants.Roles.Member
}
func (u *User) IsSupporter() bool {
return u.RoleID == constants.Roles.Supporter
}
func (u *User) SetVerification(verificationType string) (*Verification, error) {
if u.Verifications == nil {
u.Verifications = []Verification{}
}
v, err := CreateVerification(verificationType)
if err != nil {
return nil, err
}
v.UserID = u.ID
if vi := slices.IndexFunc(u.Verifications, func(vsl Verification) bool { return vsl.Type == v.Type }); vi > -1 {
u.Verifications[vi] = *v
} else {
u.Verifications = append(u.Verifications, *v)
}
return v, nil
}
func (u *User) FindVerification(verificationType string) (*Verification, error) {
if u.Verifications == nil {
return nil, errors.ErrNoData
}
vi := slices.IndexFunc(u.Verifications, func(vsl Verification) bool { return vsl.Type == verificationType })
if vi == -1 {
return nil, errors.ErrNotFound
}
return &u.Verifications[vi], nil
}
func (u *User) Verify(token string, verificationType string) error {
if token == "" || verificationType == "" {
logger.Error.Printf("token or verification type are empty in user.Verify")
return errors.ErrNoData
}
vi := slices.IndexFunc(u.Verifications, func(vsl Verification) bool {
return vsl.Type == verificationType && vsl.VerificationToken == token
})
if vi == -1 {
logger.Error.Printf("Couldn't find verification in users verifications")
return errors.ErrNotFound
}
return u.Verifications[vi].Validate()
}
func (u *User) Safe() map[string]interface{} {
var membership map[string]interface{} = nil
var licence map[string]interface{} = nil
var bankAccount map[string]interface{} = nil
if u.Membership != nil {
membership = map[string]interface{}{
"id": u.Membership.ID,
"start_date": u.Membership.StartDate,
"end_date": u.Membership.EndDate,
"status": u.Membership.Status,
"subscription": map[string]interface{}{
"id": u.Membership.Subscription.ID,
"name": u.Membership.Subscription.Name,
"details": u.Membership.Subscription.Details,
"conditions": u.Membership.Subscription.Conditions,
"monthly_fee": u.Membership.Subscription.MonthlyFee,
"hourly_rate": u.Membership.Subscription.HourlyRate,
"included_per_year": u.Membership.Subscription.IncludedPerYear,
"included_per_month": u.Membership.Subscription.IncludedPerMonth,
},
}
}
if u.Licence != nil {
licence = map[string]interface{}{
"id": u.Licence.ID,
"number": u.Licence.Number,
"status": u.Licence.Status,
"issued_date": u.Licence.IssuedDate,
"expiration_date": u.Licence.ExpirationDate,
"country": u.Licence.IssuingCountry,
"categories": u.Licence.Categories,
}
}
if u.BankAccount != nil {
bankAccount = map[string]interface{}{
"id": u.BankAccount.ID,
"mandate_date_signed": u.BankAccount.MandateDateSigned,
"bank": u.BankAccount.Bank,
"account_holder_name": u.BankAccount.AccountHolderName,
"iban": u.BankAccount.IBAN,
"bic": u.BankAccount.BIC,
"mandate_reference": u.BankAccount.MandateReference,
}
}
result := map[string]interface{}{
"email": u.Email,
"first_name": u.FirstName,
"last_name": u.LastName,
"phone": u.Phone,
"notes": u.Notes,
"address": u.Address,
"zip_code": u.ZipCode,
"city": u.City,
"status": u.Status,
"id": u.ID,
"role_id": u.RoleID,
"company": u.Company,
"dateofbirth": u.DateOfBirth,
"membership": membership,
"licence": licence,
"bank_account": bankAccount,
}
return result
}
func extractUserIDFrom(tokenString string) (uint, error) {
jwtSigningMethod := jwt.SigningMethodHS256
jwtParser := jwt.NewParser(jwt.WithValidMethods([]string{jwtSigningMethod.Alg()}))
token, err := jwtParser.Parse(tokenString, func(_ *jwt.Token) (interface{}, error) {
return []byte(config.Auth.JWTSecret), nil
})
// Handle parsing errors (excluding expiration error)
if err != nil && !errors.Is(err, jwt.ErrTokenExpired) || token == nil {
logger.Error.Printf("Error parsing token: %v", err)
return 0, err
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
logger.Error.Print("Invalid token claims structure")
return 0, fmt.Errorf("invalid token claims format")
}
// Validate required session_id claim
if _, exists := claims["session_id"]; !exists {
logger.Error.Print("Missing session_id in token claims")
return 0, fmt.Errorf("missing session_id claim")
}
// Return token, claims, and original error (might be expiration)
if _, exists := claims["session_id"]; !exists {
logger.Error.Print("Missing session_id in token claims")
return 0, fmt.Errorf("missing session_id claim")
}
id, ok := claims["user_id"]
if !ok {
return 0, fmt.Errorf("missing user_id claim")
}
return uint(id.(float64)), nil
}
func GetUsersWhere(db *gorm.DB, where map[string]interface{}) (*[]User, error) {
logger.Error.Printf("where: %#v", where)
var users []User
result := db.
Preload(clause.Associations).
Preload("Membership.Subscription").
Preload("Licence.Categories").
Where(where).Find(&users)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return nil, gorm.ErrRecordNotFound
}
return nil, result.Error
}
return &users, nil
}

View File

@@ -0,0 +1,78 @@
package models
import (
"GoMembership/internal/utils"
"GoMembership/pkg/errors"
"GoMembership/pkg/logger"
"time"
"gorm.io/gorm"
)
type Verification struct {
gorm.Model
VerifiedAt *time.Time `json:"verified_at"`
VerificationToken string `json:"token"`
UserID uint `json:"user_id"`
Type string
}
func (v *Verification) Create(db *gorm.DB) error {
if err := db.Create(v).Error; err != nil {
return err
}
logger.Info.Printf("verification created: %#v", v)
// Preload all associations to return the fully populated object
return db.First(v, v.ID).Error // Refresh the verification object with all associations
}
func (v *Verification) Update(db *gorm.DB) error {
return db.Transaction(func(tx *gorm.DB) error {
// Check if the user exists in the database
var existingVerification Verification
logger.Info.Printf("updating verification: %#v", v)
if err := tx.First(&existingVerification, v.ID).Error; err != nil {
return err
}
if err := tx.Model(&existingVerification).Updates(v).Error; err != nil {
return err
}
return tx.First(v, v.ID).Error
})
}
func (v *Verification) Delete(db *gorm.DB) error {
return db.Delete(&v).Error
}
func (v *Verification) Validate() error {
if v.VerifiedAt != nil {
return errors.ErrAlreadyVerified
}
t := time.Now()
v.VerifiedAt = &t
return nil
}
func CreateVerification(verificationType string) (*Verification, error) {
token, err := GenerateVerificationToken()
if err != nil {
return nil, err
}
v := Verification{
UserID: 0,
VerificationToken: token,
Type: verificationType,
}
return &v, nil
}
func GenerateVerificationToken() (string, error) {
return utils.GenerateRandomString(32)
}

View File

@@ -0,0 +1,69 @@
package repositories
import (
"GoMembership/internal/database"
"GoMembership/internal/models"
"GoMembership/pkg/errors"
"gorm.io/gorm"
)
// CarRepository interface defines the CRUD operations
type CarRepositoryInterface interface {
Create(car *models.Car) (*models.Car, error)
GetByID(id uint) (*models.Car, error)
GetAll() ([]models.Car, error)
Update(car *models.Car) (*models.Car, error)
Delete(id uint) error
}
type CarRepository struct{}
// Create a new car
func (r *CarRepository) Create(car *models.Car) (*models.Car, error) {
if err := database.DB.Create(car).Error; err != nil {
return nil, err
}
return car, nil
}
// GetByID fetches a car by its ID
func (r *CarRepository) GetByID(id uint) (*models.Car, error) {
var car models.Car
if err := database.DB.Where("id = ?", id).First(&car).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errors.ErrNotFound
}
return nil, err
}
return &car, nil
}
// GetAll retrieves all cars
func (r *CarRepository) GetAll() ([]models.Car, error) {
var cars []models.Car
if err := database.DB.Find(&cars).Error; err != nil {
return nil, err
}
return cars, nil
}
// Update an existing car
func (r *CarRepository) Update(car *models.Car) (*models.Car, error) {
if err := database.DB.Save(car).Error; err != nil {
return nil, err
}
return car, nil
}
// Delete a car (soft delete)
func (r *CarRepository) Delete(id uint) error {
result := database.DB.Delete(&models.Car{}, id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.ErrNotFound
}
return nil
}

View File

@@ -0,0 +1,97 @@
package repositories
import (
"GoMembership/internal/database"
"gorm.io/gorm"
"GoMembership/internal/models"
)
type SubscriptionsRepositoryInterface interface {
CreateSubscription(subscription *models.Subscription) (uint, error)
UpdateSubscription(subscription *models.Subscription) (*models.Subscription, error)
GetSubscriptionNames() ([]string, error)
GetSubscriptions(where map[string]interface{}) (*[]models.Subscription, error)
// GetUsersBySubscription(id uint) (*[]models.Subscription, error)
DeleteSubscription(id *uint) error
}
type SubscriptionsRepository struct{}
func (sr *SubscriptionsRepository) CreateSubscription(subscription *models.Subscription) (uint, error) {
result := database.DB.Create(subscription)
if result.Error != nil {
return 0, result.Error
}
return subscription.ID, nil
}
func (sr *SubscriptionsRepository) UpdateSubscription(subscription *models.Subscription) (*models.Subscription, error) {
result := database.DB.Model(&models.Subscription{ID: subscription.ID}).Updates(subscription)
if result.Error != nil {
return nil, result.Error
}
return subscription, nil
}
func (sr *SubscriptionsRepository) DeleteSubscription(id *uint) error {
result := database.DB.Delete(&models.Subscription{}, id)
if result.Error != nil {
return result.Error
}
return nil
}
func GetSubscriptionByName(modelname *string) (*models.Subscription, error) {
var model models.Subscription
result := database.DB.Where("name = ?", modelname).First(&model)
if result.Error != nil {
return nil, result.Error
}
return &model, nil
}
func (sr *SubscriptionsRepository) GetSubscriptionNames() ([]string, error) {
var names []string
if err := database.DB.Model(&models.Subscription{}).Pluck("name", &names).Error; err != nil {
return []string{}, err
}
return names, nil
}
func (sr *SubscriptionsRepository) GetSubscriptions(where map[string]interface{}) (*[]models.Subscription, error) {
var subscriptions []models.Subscription
result := database.DB.Where(where).Find(&subscriptions)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
return nil, gorm.ErrRecordNotFound
}
return nil, result.Error
}
return &subscriptions, nil
}
func GetUsersBySubscription(subscriptionID uint) (*[]models.User, error) {
var users []models.User
err := database.DB.Preload("Membership").
Preload("Membership.Subscription").
Preload("BankAccount").
Preload("Licence").
Preload("Licence.Categories").
Joins("JOIN memberships ON users.id = memberships.user_id").
Joins("JOIN subscriptions ON memberships.subscription_id = subscriptions.id").
Where("subscriptions.id = ?", subscriptionID).
Find(&users).Error
if err != nil {
return nil, err
}
return &users, nil
}

View File

@@ -0,0 +1,39 @@
package routes
import (
"GoMembership/internal/controllers"
"GoMembership/internal/middlewares"
"github.com/gin-gonic/gin"
)
func RegisterRoutes(router *gin.Engine, userController *controllers.UserController, membershipcontroller *controllers.MembershipController, contactController *controllers.ContactController, licenceController *controllers.LicenceController, carController *controllers.CarController) {
router.GET("/api/users/verify/:id", userController.VerifyMailHandler)
router.POST("/api/users/register", userController.RegisterUser)
router.POST("/api/users/contact", contactController.RelayContactRequest)
router.POST("/api/users/password/request-change", userController.RequestPasswordChangeHandler)
router.PATCH("/api/users/password/change/:id", userController.ChangePassword)
router.POST("/api/users/login", userController.LoginHandler)
router.POST("/api/csp-report", middlewares.CSPReportHandling)
userRouter := router.Group("/api/auth")
userRouter.Use(middlewares.AuthMiddleware())
{
userRouter.GET("/cars", carController.GetAll)
userRouter.PUT("/cars", carController.Update)
userRouter.POST("/cars", carController.Create)
userRouter.DELETE("/cars", carController.Delete)
userRouter.GET("/users/current", userController.CurrentUserHandler)
userRouter.POST("/logout", userController.LogoutHandler)
userRouter.PUT("/users", userController.UpdateHandler)
userRouter.POST("/users", userController.RegisterUser)
userRouter.GET("/users", userController.GetAllUsers)
userRouter.DELETE("/users", userController.DeleteUser)
userRouter.PATCH("/users/activate", userController.CreatePasswordHandler)
userRouter.GET("/subscriptions", membershipcontroller.GetSubscriptions)
userRouter.PUT("/subscriptions", membershipcontroller.UpdateHandler)
userRouter.POST("/subscriptions", membershipcontroller.RegisterSubscription)
userRouter.DELETE("/subscriptions", membershipcontroller.DeleteSubscription)
userRouter.GET("/licence/categories", licenceController.GetAllCategories)
}
}

View File

@@ -20,13 +20,14 @@ import (
"GoMembership/pkg/logger" "GoMembership/pkg/logger"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm"
) )
var shutdownChannel = make(chan struct{}) var shutdownChannel = make(chan struct{})
var srv *http.Server var srv *http.Server
// Run initializes the server configuration, sets up services and controllers, and starts the HTTP server. // Run initializes the server configuration, sets up services and controllers, and starts the HTTP server.
func Run() { func Run(db *gorm.DB) {
emailService := services.NewEmailService(config.SMTP.Host, config.SMTP.Port, config.SMTP.User, config.SMTP.Password) emailService := services.NewEmailService(config.SMTP.Host, config.SMTP.Port, config.SMTP.User, config.SMTP.Password)
var consentRepo repositories.ConsentRepositoryInterface = &repositories.ConsentRepository{} var consentRepo repositories.ConsentRepositoryInterface = &repositories.ConsentRepository{}
@@ -36,19 +37,19 @@ func Run() {
bankAccountService := &services.BankAccountService{Repo: bankAccountRepo} bankAccountService := &services.BankAccountService{Repo: bankAccountRepo}
var membershipRepo repositories.MembershipRepositoryInterface = &repositories.MembershipRepository{} var membershipRepo repositories.MembershipRepositoryInterface = &repositories.MembershipRepository{}
var subscriptionRepo repositories.SubscriptionModelsRepositoryInterface = &repositories.SubscriptionModelsRepository{} var subscriptionRepo repositories.SubscriptionsRepositoryInterface = &repositories.SubscriptionsRepository{}
membershipService := &services.MembershipService{Repo: membershipRepo, SubscriptionRepo: subscriptionRepo} membershipService := &services.MembershipService{Repo: membershipRepo, SubscriptionRepo: subscriptionRepo}
var licenceRepo repositories.LicenceInterface = &repositories.LicenceRepository{} var licenceRepo repositories.LicenceInterface = &repositories.LicenceRepository{}
licenceService := &services.LicenceService{Repo: licenceRepo} licenceService := &services.LicenceService{Repo: licenceRepo}
userService := &services.UserService{DB: db, Licences: licenceRepo}
var userRepo repositories.UserRepositoryInterface = &repositories.UserRepository{}
userService := &services.UserService{Repo: userRepo, Licences: licenceRepo}
userController := &controllers.UserController{Service: userService, EmailService: emailService, ConsentService: consentService, LicenceService: licenceService, BankAccountService: bankAccountService, MembershipService: membershipService} userController := &controllers.UserController{Service: userService, EmailService: emailService, ConsentService: consentService, LicenceService: licenceService, BankAccountService: bankAccountService, MembershipService: membershipService}
membershipController := &controllers.MembershipController{Service: *membershipService, UserController: userController} membershipController := &controllers.MembershipController{Service: membershipService, UserService: userService}
licenceController := &controllers.LicenceController{Service: *licenceService} licenceController := &controllers.LicenceController{Service: licenceService}
contactController := &controllers.ContactController{EmailService: emailService} contactController := &controllers.ContactController{EmailService: emailService}
carService := &services.CarService{DB: db}
carController := &controllers.CarController{S: carService, UserService: userService}
router := gin.Default() router := gin.Default()
// gin.SetMode(gin.ReleaseMode) // gin.SetMode(gin.ReleaseMode)
@@ -64,8 +65,8 @@ func Run() {
limiter := middlewares.NewIPRateLimiter(config.Security.Ratelimits.Limit, config.Security.Ratelimits.Burst) limiter := middlewares.NewIPRateLimiter(config.Security.Ratelimits.Limit, config.Security.Ratelimits.Burst)
router.Use(middlewares.RateLimitMiddleware(limiter)) router.Use(middlewares.RateLimitMiddleware(limiter))
routes.RegisterRoutes(router, userController, membershipController, contactController, licenceController) routes.RegisterRoutes(router, userController, membershipController, contactController, licenceController, carController)
validation.SetupValidators() validation.SetupValidators(db)
logger.Info.Println("Starting server on :8080") logger.Info.Println("Starting server on :8080")
srv = &http.Server{ srv = &http.Server{

View File

@@ -0,0 +1,67 @@
package services
import (
"GoMembership/internal/models"
"gorm.io/gorm"
)
type CarServiceInterface interface {
Create(car *models.Car) (*models.Car, error)
Update(car *models.Car) (*models.Car, error)
Delete(carID *uint) error
FromID(id uint) (*models.Car, error)
GetAll() (*[]models.Car, error)
}
type CarService struct {
DB *gorm.DB
}
// Create a new car
func (s *CarService) Create(car *models.Car) (*models.Car, error) {
err := car.Create(s.DB)
if err != nil {
return nil, err
}
return car, nil
}
// Update an existing car
func (s *CarService) Update(car *models.Car) (*models.Car, error) {
err := car.Update(s.DB)
if err != nil {
return nil, err
}
return car, nil
}
// Delete a car (soft delete)
func (s *CarService) Delete(carID *uint) error {
var car models.Car
err := car.FromID(s.DB, *carID)
if err != nil {
return err
}
return car.Delete(s.DB)
}
// GetByID fetches a car by its ID
func (s *CarService) FromID(id uint) (*models.Car, error) {
car := &models.Car{}
err := car.FromID(s.DB, id)
if err != nil {
return nil, err
}
return car, nil
}
// GetAll retrieves all cars
func (s *CarService) GetAll() (*[]models.Car, error) {
cars, err := models.GetAllCars(s.DB)
if err != nil {
return nil, err
}
return &cars, nil
}

View File

@@ -21,7 +21,7 @@ func NewEmailService(host string, port int, username string, password string) *E
return &EmailService{dialer: dialer} return &EmailService{dialer: dialer}
} }
func (s *EmailService) SendEmail(to string, subject string, body string, replyTo string) error { func (s *EmailService) SendEmail(to string, subject string, body string, bodyTXT string, replyTo string) error {
msg := gomail.NewMessage() msg := gomail.NewMessage()
msg.SetHeader("From", s.dialer.Username) msg.SetHeader("From", s.dialer.Username)
msg.SetHeader("To", to) msg.SetHeader("To", to)
@@ -29,7 +29,12 @@ func (s *EmailService) SendEmail(to string, subject string, body string, replyTo
if replyTo != "" { if replyTo != "" {
msg.SetHeader("REPLY_TO", replyTo) msg.SetHeader("REPLY_TO", replyTo)
} }
msg.SetBody("text/html", body) if bodyTXT != "" {
msg.SetBody("text/plain", bodyTXT)
}
msg.AddAlternative("text/html", body)
// msg.WriteTo(os.Stdout)
if err := s.dialer.DialAndSend(msg); err != nil { if err := s.dialer.DialAndSend(msg); err != nil {
logger.Error.Printf("Could not send email to %s: %v", to, err) logger.Error.Printf("Could not send email to %s: %v", to, err)
@@ -41,7 +46,7 @@ func (s *EmailService) SendEmail(to string, subject string, body string, replyTo
func ParseTemplate(filename string, data interface{}) (string, error) { func ParseTemplate(filename string, data interface{}) (string, error) {
// Read the email template file // Read the email template file
logger.Error.Printf("Data: %#v", data)
templateDir := config.Templates.MailPath templateDir := config.Templates.MailPath
tpl, err := template.ParseFiles(templateDir + "/" + filename) tpl, err := template.ParseFiles(templateDir + "/" + filename)
if err != nil { if err != nil {
@@ -66,20 +71,93 @@ func (s *EmailService) SendVerificationEmail(user *models.User, token *string) e
LastName string LastName string
Token string Token string
BASEURL string BASEURL string
UserID uint
Logo string
}{ }{
FirstName: user.FirstName, FirstName: user.FirstName,
LastName: user.LastName, LastName: user.LastName,
Token: *token, Token: *token,
BASEURL: config.Site.BaseURL, BASEURL: config.Site.BaseURL,
UserID: user.ID,
Logo: config.Templates.LogoURI,
} }
logger.Error.Printf("USERIID: %#v", user.ID)
subject := constants.MailVerificationSubject subject := constants.MailVerificationSubject
body, err := ParseTemplate("mail_verification.tmpl", data) body, err := ParseTemplate("mail_verification.tmpl", data)
if err != nil { if err != nil {
logger.Error.Print("Couldn't send verification mail") logger.Error.Print("Couldn't send verification mail")
return err return err
} }
return s.SendEmail(user.Email, subject, body, "") return s.SendEmail(user.Email, subject, body, "", "")
}
func (s *EmailService) SendGrantBackendAccessEmail(user *models.User, token *string) error {
// Prepare data to be injected into the template
data := struct {
FirstName string
LastName string
Token string
BASEURL string
FRONTEND_PATH string
UserID uint
Logo string
}{
FirstName: user.FirstName,
LastName: user.LastName,
Token: *token,
FRONTEND_PATH: config.Site.FrontendPath,
BASEURL: config.Site.BaseURL,
UserID: user.ID,
Logo: config.Templates.LogoURI,
}
subject := constants.MailGrantBackendAccessSubject
htmlBody, err := ParseTemplate("mail_grant_backend_access.tmpl", data)
if err != nil {
logger.Error.Print("Couldn't send grant backend access mail")
return err
}
plainBody, err := ParseTemplate("mail_grant_backend_access.txt.tmpl", data)
if err != nil {
logger.Error.Print("Couldn't parse password mail")
return err
}
return s.SendEmail(user.Email, subject, htmlBody, plainBody, "")
}
func (s *EmailService) SendChangePasswordEmail(user *models.User, token *string) error {
// Prepare data to be injected into the template
data := struct {
FirstName string
LastName string
Token string
BASEURL string
FRONTEND_PATH string
UserID uint
Logo string
}{
FirstName: user.FirstName,
LastName: user.LastName,
Token: *token,
FRONTEND_PATH: config.Site.FrontendPath,
BASEURL: config.Site.BaseURL,
UserID: user.ID,
Logo: config.Templates.LogoURI,
}
subject := constants.MailChangePasswordSubject
htmlBody, err := ParseTemplate("mail_change_password.tmpl", data)
if err != nil {
logger.Error.Print("Couldn't parse password mail")
return err
}
plainBody, err := ParseTemplate("mail_change_password.txt.tmpl", data)
if err != nil {
logger.Error.Print("Couldn't parse password mail")
return err
}
return s.SendEmail(user.Email, subject, htmlBody, plainBody, "")
} }
@@ -98,22 +176,27 @@ func (s *EmailService) SendWelcomeEmail(user *models.User) error {
}{ }{
Company: user.Company, Company: user.Company,
FirstName: user.FirstName, FirstName: user.FirstName,
MembershipModel: user.Membership.SubscriptionModel.Name, MembershipModel: user.Membership.Subscription.Name,
MembershipID: user.Membership.ID, MembershipID: user.Membership.ID,
MembershipFee: float32(user.Membership.SubscriptionModel.MonthlyFee), MembershipFee: float32(user.Membership.Subscription.MonthlyFee),
RentalFee: float32(user.Membership.SubscriptionModel.HourlyRate), RentalFee: float32(user.Membership.Subscription.HourlyRate),
BASEURL: config.Site.BaseURL, BASEURL: config.Site.BaseURL,
WebsiteTitle: config.Site.WebsiteTitle, WebsiteTitle: config.Site.WebsiteTitle,
Logo: config.Templates.LogoURI, Logo: config.Templates.LogoURI,
} }
subject := constants.MailWelcomeSubject subject := constants.MailWelcomeSubject
body, err := ParseTemplate("mail_welcome.tmpl", data) htmlBody, err := ParseTemplate("mail_welcome.tmpl", data)
if err != nil { if err != nil {
logger.Error.Print("Couldn't send welcome mail") logger.Error.Print("Couldn't send welcome mail")
return err return err
} }
return s.SendEmail(user.Email, subject, body, "") plainBody, err := ParseTemplate("mail_welcome.txt.tmpl", data)
if err != nil {
logger.Error.Print("Couldn't parse password mail")
return err
}
return s.SendEmail(user.Email, subject, htmlBody, plainBody, "")
} }
func (s *EmailService) SendRegistrationNotification(user *models.User) error { func (s *EmailService) SendRegistrationNotification(user *models.User) error {
@@ -140,10 +223,10 @@ func (s *EmailService) SendRegistrationNotification(user *models.User) error {
Company: user.Company, Company: user.Company,
FirstName: user.FirstName, FirstName: user.FirstName,
LastName: user.LastName, LastName: user.LastName,
MembershipModel: user.Membership.SubscriptionModel.Name, MembershipModel: user.Membership.Subscription.Name,
MembershipID: user.Membership.ID, MembershipID: user.Membership.ID,
MembershipFee: float32(user.Membership.SubscriptionModel.MonthlyFee), MembershipFee: float32(user.Membership.Subscription.MonthlyFee),
RentalFee: float32(user.Membership.SubscriptionModel.HourlyRate), RentalFee: float32(user.Membership.Subscription.HourlyRate),
Address: user.Address, Address: user.Address,
ZipCode: user.ZipCode, ZipCode: user.ZipCode,
City: user.City, City: user.City,
@@ -157,12 +240,17 @@ func (s *EmailService) SendRegistrationNotification(user *models.User) error {
} }
subject := constants.MailRegistrationSubject subject := constants.MailRegistrationSubject
body, err := ParseTemplate("mail_registration.tmpl", data) htmlBody, err := ParseTemplate("mail_registration.tmpl", data)
if err != nil { if err != nil {
logger.Error.Print("Couldn't send admin notification mail") logger.Error.Print("Couldn't send admin notification mail")
return err return err
} }
return s.SendEmail(config.Recipients.UserRegistration, subject, body, "") plainBody, err := ParseTemplate("mail_registration.txt.tmpl", data)
if err != nil {
logger.Error.Print("Couldn't parse password mail")
return err
}
return s.SendEmail(config.Recipients.UserRegistration, subject, htmlBody, plainBody, "")
} }
func (s *EmailService) RelayContactFormMessage(sender string, name string, message string) error { func (s *EmailService) RelayContactFormMessage(sender string, name string, message string) error {
@@ -180,10 +268,15 @@ func (s *EmailService) RelayContactFormMessage(sender string, name string, messa
WebsiteTitle: config.Site.WebsiteTitle, WebsiteTitle: config.Site.WebsiteTitle,
} }
subject := constants.MailContactSubject subject := constants.MailContactSubject
body, err := ParseTemplate("mail_contact_form.tmpl", data) htmlBody, err := ParseTemplate("mail_contact_form.tmpl", data)
if err != nil { if err != nil {
logger.Error.Print("Couldn't send contact form message mail") logger.Error.Print("Couldn't send contact form message mail")
return err return err
} }
return s.SendEmail(config.Recipients.ContactForm, subject, body, sender) plainBody, err := ParseTemplate("mail_contact_form.txt.tmpl", data)
if err != nil {
logger.Error.Print("Couldn't parse password mail")
return err
}
return s.SendEmail(config.Recipients.ContactForm, subject, htmlBody, plainBody, sender)
} }

View File

@@ -5,7 +5,7 @@ import (
"GoMembership/internal/repositories" "GoMembership/internal/repositories"
) )
type LicenceInterface interface { type LicenceServiceInterface interface {
GetAllCategories() ([]models.Category, error) GetAllCategories() ([]models.Category, error)
} }

View File

@@ -11,17 +11,17 @@ import (
type MembershipServiceInterface interface { type MembershipServiceInterface interface {
RegisterMembership(membership *models.Membership) (uint, error) RegisterMembership(membership *models.Membership) (uint, error)
FindMembershipByUserID(userID uint) (*models.Membership, error) FindMembershipByUserID(userID uint) (*models.Membership, error)
RegisterSubscription(subscription *models.SubscriptionModel) (uint, error) RegisterSubscription(subscription *models.Subscription) (uint, error)
UpdateSubscription(subscription *models.SubscriptionModel) (*models.SubscriptionModel, error) UpdateSubscription(subscription *models.Subscription) (*models.Subscription, error)
DeleteSubscription(id *uint, name *string) error DeleteSubscription(id *uint, name *string) error
GetSubscriptionModelNames() ([]string, error) GetSubscriptionNames() ([]string, error)
GetSubscriptionByName(modelname *string) (*models.SubscriptionModel, error) GetSubscriptionByName(modelname *string) (*models.Subscription, error)
GetSubscriptions(where map[string]interface{}) (*[]models.SubscriptionModel, error) GetSubscriptions(where map[string]interface{}) (*[]models.Subscription, error)
} }
type MembershipService struct { type MembershipService struct {
Repo repositories.MembershipRepositoryInterface Repo repositories.MembershipRepositoryInterface
SubscriptionRepo repositories.SubscriptionModelsRepositoryInterface SubscriptionRepo repositories.SubscriptionsRepositoryInterface
} }
func (service *MembershipService) RegisterMembership(membership *models.Membership) (uint, error) { func (service *MembershipService) RegisterMembership(membership *models.Membership) (uint, error) {
@@ -29,15 +29,16 @@ func (service *MembershipService) RegisterMembership(membership *models.Membersh
return service.Repo.CreateMembership(membership) return service.Repo.CreateMembership(membership)
} }
func (service *MembershipService) UpdateSubscription(subscription *models.SubscriptionModel) (*models.SubscriptionModel, error) { func (service *MembershipService) UpdateSubscription(subscription *models.Subscription) (*models.Subscription, error) {
existingSubscription, err := repositories.GetSubscriptionByName(&subscription.Name) existingSubscription, err := repositories.GetSubscriptionByName(&subscription.Name)
if err != nil { if err != nil {
return nil, err if err.Error() == "record not found" {
}
if existingSubscription == nil {
return nil, errors.ErrSubscriptionNotFound return nil, errors.ErrSubscriptionNotFound
} }
return nil, err
}
if existingSubscription.MonthlyFee != subscription.MonthlyFee || if existingSubscription.MonthlyFee != subscription.MonthlyFee ||
existingSubscription.HourlyRate != subscription.HourlyRate || existingSubscription.HourlyRate != subscription.HourlyRate ||
existingSubscription.Conditions != subscription.Conditions || existingSubscription.Conditions != subscription.Conditions ||
@@ -56,11 +57,12 @@ func (service *MembershipService) DeleteSubscription(id *uint, name *string) err
} }
exists, err := repositories.GetSubscriptionByName(name) exists, err := repositories.GetSubscriptionByName(name)
if err != nil { if err != nil {
if err.Error() == "record not found" {
return errors.ErrSubscriptionNotFound
}
return err return err
} }
if exists == nil {
return errors.ErrNotFound
}
if *id != exists.ID { if *id != exists.ID {
return errors.ErrInvalidSubscriptionData return errors.ErrInvalidSubscriptionData
} }
@@ -80,19 +82,19 @@ func (service *MembershipService) FindMembershipByUserID(userID uint) (*models.M
} }
// Membership_Subscriptions // Membership_Subscriptions
func (service *MembershipService) RegisterSubscription(subscription *models.SubscriptionModel) (uint, error) { func (service *MembershipService) RegisterSubscription(subscription *models.Subscription) (uint, error) {
return service.SubscriptionRepo.CreateSubscriptionModel(subscription) return service.SubscriptionRepo.CreateSubscription(subscription)
} }
func (service *MembershipService) GetSubscriptionModelNames() ([]string, error) { func (service *MembershipService) GetSubscriptionNames() ([]string, error) {
return service.SubscriptionRepo.GetSubscriptionModelNames() return service.SubscriptionRepo.GetSubscriptionNames()
} }
func (service *MembershipService) GetSubscriptionByName(modelname *string) (*models.SubscriptionModel, error) { func (service *MembershipService) GetSubscriptionByName(modelname *string) (*models.Subscription, error) {
return repositories.GetSubscriptionByName(modelname) return repositories.GetSubscriptionByName(modelname)
} }
func (service *MembershipService) GetSubscriptions(where map[string]interface{}) (*[]models.SubscriptionModel, error) { func (service *MembershipService) GetSubscriptions(where map[string]interface{}) (*[]models.Subscription, error) {
if where == nil { if where == nil {
where = map[string]interface{}{} where = map[string]interface{}{}
} }

View File

@@ -0,0 +1,126 @@
package services
import (
"strings"
"GoMembership/internal/constants"
"GoMembership/internal/models"
"GoMembership/internal/repositories"
"GoMembership/pkg/errors"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"time"
)
type UserServiceInterface interface {
Register(user *models.User) (id uint, token string, err error)
Update(user *models.User) (*models.User, error)
Delete(id *uint) error
FromContext(c *gin.Context) (*models.User, error)
FromID(id *uint) (*models.User, error)
FromEmail(email *string) (*models.User, error)
GetUsers(where map[string]interface{}) (*[]models.User, error)
}
type UserService struct {
Licences repositories.LicenceInterface
DB *gorm.DB
}
func (s *UserService) FromContext(c *gin.Context) (*models.User, error) {
var user models.User
if err := user.FromContext(s.DB, c); err != nil {
return nil, err
}
return &user, nil
}
func (s *UserService) FromID(id *uint) (*models.User, error) {
var user models.User
if err := user.FromID(s.DB, id); err != nil {
return nil, err
}
return &user, nil
}
func (s *UserService) FromEmail(email *string) (*models.User, error) {
var user models.User
if err := user.FromEmail(s.DB, email); err != nil {
return nil, err
}
return &user, nil
}
func (s *UserService) Delete(id *uint) error {
var user models.User
if err := user.FromID(s.DB, id); err != nil {
return err
}
return user.Delete(s.DB)
}
func (s *UserService) Update(user *models.User) (*models.User, error) {
var existingUser models.User
if err := existingUser.FromID(s.DB, &user.ID); err != nil {
return nil, err
}
user.Membership.ID = existingUser.Membership.ID
if existingUser.Licence != nil {
user.Licence.ID = existingUser.Licence.ID
}
user.BankAccount.ID = existingUser.BankAccount.ID
// Validate subscription model
selectedModel, err := repositories.GetSubscriptionByName(&user.Membership.Subscription.Name)
if err != nil {
return nil, errors.ErrSubscriptionNotFound
}
user.Membership.Subscription = *selectedModel
user.Membership.SubscriptionID = selectedModel.ID
if err := user.Update(s.DB); err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.ErrUserNotFound
}
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
return nil, errors.ErrDuplicateEntry
}
return nil, err
}
return user, nil
}
func (s *UserService) Register(user *models.User) (id uint, token string, err error) {
selectedModel, err := repositories.GetSubscriptionByName(&user.Membership.Subscription.Name)
if err != nil {
return 0, "", errors.ErrSubscriptionNotFound
}
user.Membership.Subscription = *selectedModel
user.Membership.SubscriptionID = selectedModel.ID
user.Status = constants.UnverifiedStatus
user.BankAccount.MandateDateSigned = time.Now()
v, err := user.SetVerification(constants.VerificationTypes.Email)
if err != nil {
return 0, "", err
}
if err := user.Create(s.DB); err != nil {
return 0, "", err
}
return user.ID, v.VerificationToken, nil
}
// GetUsers returns a list of users based on the provided where clause.
// if where == nil: all users are returned
func (s *UserService) GetUsers(where map[string]interface{}) (*[]models.User, error) {
if where == nil {
where = map[string]interface{}{}
}
return models.GetUsersWhere(s.DB, where)
}

View File

@@ -30,10 +30,6 @@ func GenerateRandomString(length int) (string, error) {
return base64.URLEncoding.EncodeToString(bytes), nil return base64.URLEncoding.EncodeToString(bytes), nil
} }
func GenerateVerificationToken() (string, error) {
return GenerateRandomString(32)
}
func DecodeMail(message string) (*Email, error) { func DecodeMail(message string) (*Email, error) {
msg, err := mail.ReadMessage(strings.NewReader(message)) msg, err := mail.ReadMessage(strings.NewReader(message))
if err != nil { if err != nil {

Some files were not shown because too many files have changed in this diff Show More