Compare commits

...

269 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
Alex
3493e83e84 backend: priviliges 2025-02-20 13:19:41 +01:00
Alex
03b3683b63 frontend moved select fields to themed coloring, added permissions to usereditmodal 2025-02-20 10:16:48 +01:00
Alex
d5a8b16e43 frontend:add user count badge on subscriptions page 2025-02-20 09:07:31 +01:00
Alex
48e21736ea frontend: admin/users/layout.server cleanup 2025-02-20 09:06:51 +01:00
Alex
ab168311a9 new routes 2025-02-20 09:06:27 +01:00
Alex
54faee731d frontend: fix empty date handling 2025-02-20 09:05:49 +01:00
Alex
4a539638f8 .gitignore 2025-02-19 17:33:06 +01:00
Alex
09a0c9bba9 how did this disappear? ;) 2025-02-19 17:32:52 +01:00
Alex
9472577d5e frontend adapted hooks.server to new refreshcookie 2025-02-19 12:08:30 +01:00
Alex
f180f59546 style: login screen 2025-02-19 12:07:59 +01:00
Alex
0e12286f15 frontend: add search and mailto 2025-02-19 12:07:40 +01:00
Alex
cf037db080 frontend:refactor layout.server 2025-02-19 12:07:27 +01:00
Alex
012a57956a frontend: fix refreshCookie 2025-02-19 12:06:51 +01:00
Alex
6c18accae4 locale 2025-02-19 12:06:30 +01:00
Alex
2b500ca187 css: moved header styles to header component 2025-02-19 12:06:22 +01:00
Alex
afe0a0de54 frontend: add custom background color to inputField 2025-02-19 12:05:43 +01:00
Alex
3b08e49d6f frontend: refactor header 2025-02-19 12:05:20 +01:00
Alex
d688101378 frontend: fixed missing typing in utils 2025-02-18 13:48:39 +01:00
Alex
e9d6b58f20 frontend: admin/users page delete inserts and error handling 2025-02-18 11:37:47 +01:00
Alex
42edc70490 subscriptionEditForm: permissions 2025-02-18 11:37:16 +01:00
Alex
64b368e617 frontend usereditform locale 2025-02-18 11:36:19 +01:00
Alex
e11a05a85f frontend formatting login page 2025-02-18 11:35:27 +01:00
Alex
89841ade55 locale 2025-02-18 11:34:28 +01:00
Alex
89a7780c54 backend refactor: userdeletion 2025-02-18 11:34:21 +01:00
Alex
9d83afa525 add: deletesubscription 2025-02-18 11:33:21 +01:00
Alex
d1273d3e23 backend: add: new error messages 2025-02-18 11:32:33 +01:00
Alex
0fab8791f9 locale 2025-02-18 11:31:45 +01:00
Alex
59d9169646 fix cookies 2025-02-18 11:31:27 +01:00
Alex
f1fe64855d add: light theme 2025-02-18 11:31:01 +01:00
Alex
743493517b Backend: add Subscription CRUD 2025-02-12 20:30:59 +01:00
Alex
8787c8c2c1 delete static remnants 2025-02-12 20:30:25 +01:00
Alex
993d7920d4 Frontend: Locale 2025-02-12 20:30:12 +01:00
Alex
861d029ce5 Tests: Membership controller 2025-02-12 10:12:30 +01:00
Alex
2fdb484451 frontend: added subscription processing 2025-02-11 19:17:30 +01:00
Alex
2492f410b1 frontend:locale & subscription handling 2025-02-11 13:27:14 +01:00
Alex
447f149423 licence_categories->categories;frontend: fixed category handling 2025-02-10 14:59:22 +01:00
Alex
a8bc049af7 backend db fixes 2025-02-10 12:52:12 +01:00
Alex
c34c46cbc2 backend: add: DeleteUser, fix: validation 2025-02-08 18:28:07 +01:00
Alex
32a473fe29 Frontend: licence_number>number, password update enabled 2025-02-08 18:26:34 +01:00
Alex
cce2866b52 frontend cleanup 2025-02-07 22:00:43 +01:00
Alex
3ae1ffd403 backend: fix: err messages 2025-02-07 21:59:55 +01:00
Alex
77619c42bd backend updateUser fix 2025-02-07 21:43:54 +01:00
Alex
67ef3a2fca styling, DateOfBirth corrected 2025-01-31 19:27:15 +01:00
Alex
c2d5188765 styling 2025-01-29 16:02:37 +01:00
Alex
f68ca9abc5 new setup, added missed configs 2025-01-28 21:25:52 +01:00
Alex
183e4da7f4 Backend:Real world movement 2025-01-16 14:24:21 +01:00
Alex
11c55a17ea frontend: real world movement 2025-01-16 14:23:54 +01:00
Alex
66ce257198 add: Privilige check 2024-11-04 17:22:07 +01:00
Alex
fa10a0a507 moved to struct validation; 2024-11-04 17:21:55 +01:00
Alex
0fa57bfe75 made Licence optional 2024-11-04 17:19:32 +01:00
Alex
eb7fc359e1 fix: typo; refactor 2024-11-04 17:16:18 +01:00
Alex
6b57dd7cf6 add bootstrap 2024-10-13 19:44:59 +02:00
Alex
5e32c6b431 mod: db migration 2024-10-13 13:43:03 +02:00
Alex
7ed986d122 add: getAllUsers 2024-10-13 13:42:39 +02:00
Alex
58daf7bf30 removed library remains, add helper usage in hooks 2024-10-13 13:41:36 +02:00
Alex
d3365ae065 moved user sanitation to helper.js 2024-10-13 13:39:51 +02:00
Alex
8b3e8a0579 del: about layout 2024-10-13 13:39:06 +02:00
Alex
ab8d143aeb add usermamagement page 2024-10-13 13:38:30 +02:00
Alex
47e4e8ce55 add: UserManagement to header 2024-10-13 13:37:30 +02:00
Alex
20012b729e delete obsolote utils.js 2024-10-13 13:07:37 +02:00
Alex
975e3121a5 locales 2024-10-13 12:21:27 +02:00
Alex
56a28bbff7 add:prep for create user 2024-10-13 12:19:46 +02:00
Alex
fcfc8ad1e0 moved subscriptions and licences requests to subpages & renaming 2024-10-10 21:54:09 +02:00
Alex
d54f2ae2e6 new licenceController, moved api handling & renaming things 2024-10-10 20:54:20 +02:00
Alex
fca5af2c9a add: UserEditForm 2024-10-10 07:04:50 +02:00
Alex
79ffb5051c chg: admin creation 2024-10-09 18:12:54 +02:00
Alex
5de2f31e5f chg: nested struct loading 2024-10-09 18:12:40 +02:00
Alex
b2e4947d37 add & moved to validations folder; del validator/v10 2024-10-09 18:12:20 +02:00
Alex
6aee416b63 chg: moved to gin binding instead of validator 2024-10-09 18:09:49 +02:00
Alex
02d75f0ab1 chg: backend: error struct 2024-10-09 18:08:33 +02:00
Alex
3b3dc9d251 chg: frontend: routes: errorhandling 2024-10-09 18:07:28 +02:00
Alex
c008bcad0b chg: frontend errorFormating 2024-10-09 18:05:27 +02:00
Alex
451e42a1fc add: locales for server errors 2024-10-09 18:01:43 +02:00
Alex
4de5a54cac fix: date formatting 2024-10-08 07:39:21 +02:00
Alex
1367ba4fa1 grouped and sorted licence categories display 2024-10-08 07:39:03 +02:00
Alex
9023979b43 add mandate_date_signed formatting 2024-10-08 07:37:45 +02:00
Alex
8071bba435 add: mandate_date_signed locale 2024-10-08 07:36:32 +02:00
Alex
a947075e80 mod: licence category locale 2024-10-08 07:36:16 +02:00
Alex
34a6ecd039 add default RFC3339 datestring 2024-10-08 07:35:42 +02:00
Alex
4316eaad79 removed commented Avatar stuff 2024-10-08 07:35:24 +02:00
Alex
a0e1b32b19 add: mandate_date_signed display 2024-10-08 07:33:28 +02:00
Alex
68b78cc443 typo 2024-10-08 07:32:59 +02:00
Alex
bdcc98a2ac add: readonly option for inputfields 2024-10-08 07:32:29 +02:00
Alex
e14642ed66 fix: missing user.Safe fields 2024-10-07 11:41:52 +02:00
Alex
55afc6bee9 chg admin creation 2024-10-07 11:41:31 +02:00
Alex
0c2aa68311 typo 2024-10-07 11:41:05 +02:00
Alex
580b022c42 del: Avatar image 2024-10-07 11:40:58 +02:00
Alex
dfbeabc93c add: driverslicence update 2024-10-07 11:39:53 +02:00
Alex
0314076669 translation 2024-10-07 11:37:44 +02:00
Alex
cafe030e79 add checkbox styling, driverslicence handling and validation 2024-10-02 14:32:14 +02:00
Alex
4ee18f21f2 cleanup 2024-09-30 16:28:05 +02:00
Alex
4bd10d24d2 about page changes 2024-09-29 21:30:21 +02:00
Alex
aea61e9440 chg backend urls 2024-09-29 21:29:44 +02:00
Alex
5f1cb4e84f fix typos 2024-09-29 21:29:23 +02:00
Alex
e56b58bd5f add utils: date conversion 2024-09-29 21:28:54 +02:00
Alex
f0ed8e3446 locale 2024-09-29 21:28:14 +02:00
Alex
27e1048d41 styles 2024-09-29 21:28:06 +02:00
Alex
797aca3bb1 add InputField select option & input validation 2024-09-29 21:27:57 +02:00
Alex
f7c1ad2b8e add: Subscriptions receival 2024-09-29 21:26:48 +02:00
Alex
8e9eb22cb0 changed defaults for app.d.ts interfaces 2024-09-29 21:23:05 +02:00
Alex
db84918cb1 frontend: changed thumbnail to profile picture 2024-09-29 21:22:23 +02:00
Alex
dbf7aca078 add: Tests for driverslicence 2024-09-29 21:18:02 +02:00
Alex
b99a5010a7 add default for userCreation 2024-09-29 21:17:25 +02:00
Alex
8a581da1d8 add driversLicence model 2024-09-29 21:16:58 +02:00
Alex
41738753f0 add subscriptions to currentUser JSON for frontend 2024-09-29 21:14:03 +02:00
Alex
33561692b6 membership status now int; add auto SEPA mandateref upon user creation 2024-09-29 21:12:49 +02:00
Alex
31cfe21695 clean up of bank-service 2024-09-29 21:08:59 +02:00
Alex
36bd75bbeb add where clause default to membershipservice 2024-09-29 21:07:22 +02:00
Alex
2acbe703eb add: subscription seeding, enhance admin creation 2024-09-29 21:06:30 +02:00
Alex
72017c97ff add: UserRole for privelige handling 2024-09-29 21:03:32 +02:00
Alex
1ded8bee33 moved db indices to uint 2024-09-29 20:58:42 +02:00
$(pass /github/name)
e0cc893493 frontend fix: Cookie passthrough 2024-09-20 08:39:47 +02:00
$(pass /github/name)
43a039ae91 frontend:add:error handling on failed validation 2024-09-20 08:38:47 +02:00
$(pass /github/name)
49986ffd79 frontend:chg:generalized form field serialization 2024-09-20 08:37:58 +02:00
$(pass /github/name)
0897361260 frontend:adapted to new hooks 2024-09-20 08:36:06 +02:00
$(pass /github/name)
211cf1db3f frontend:add:cookie renewal 2024-09-20 08:35:15 +02:00
$(pass /github/name)
30d56ce778 locales 2024-09-20 08:30:40 +02:00
$(pass /github/name)
3ec32756c8 front:add: InputField component 2024-09-20 08:30:20 +02:00
$(pass /github/name)
00facf8758 add: update handling 2024-09-20 08:29:00 +02:00
$(pass /github/name)
62624cd0f8 membership input validation improved & tests 2024-09-20 08:28:23 +02:00
$(pass /github/name)
361fa1316a add sql injection test 2024-09-20 08:27:34 +02:00
$(pass /github/name)
851e62dbac add xss validation 2024-09-20 08:26:07 +02:00
$(pass /github/name)
1e68e7d390 Add: session handling 2024-09-20 08:25:26 +02:00
$(pass /github/name)
31c47270ab chg Routing again 2024-09-20 08:24:42 +02:00
$(pass /github/name)
81e9068eba add: Cookie generation 2024-09-20 08:00:24 +02:00
$(pass /github/name)
74ef7efdec add: custom errors 2024-09-20 07:58:17 +02:00
$(pass /github/name)
46afa417b7 xss mitigation & test 2024-09-20 07:57:54 +02:00
$(pass /github/name)
b34a85e9d6 add: frontend profile data etc 2024-09-10 18:52:32 +02:00
$(pass /github/name)
f0b2409963 fix: current-user route 2024-09-07 13:37:26 +02:00
$(pass /github/name)
147b8c0afd frontend: initial commit 2024-09-07 13:36:15 +02:00
$(pass /github/name)
4d6938de96 add: logout handling 2024-09-07 11:38:35 +02:00
$(pass /github/name)
c3944cb4aa add: Safe func to user model 2024-09-07 10:36:21 +02:00
$(pass /github/name)
f99ff57275 minor 2024-09-07 08:56:33 +02:00
$(pass /github/name)
c36af961f3 fix auth routes 2024-09-07 08:56:26 +02:00
$(pass /github/name)
b3dc134c8c add admin user creation 2024-09-07 08:56:15 +02:00
$(pass /github/name)
066419e546 fix: user model json handling; user_controller_test debug logging, user_controller 2024-09-07 08:55:39 +02:00
$(pass /github/name)
ff7c83671f fix: auth && auth_test 2024-09-07 08:53:57 +02:00
$(pass /github/name)
ef5e771998 chg: cors allowOrigins now configurable 2024-09-07 08:52:48 +02:00
$(pass /github/name)
8f3d73af90 add: AllowOrigins config && changed config format 2024-09-07 08:51:58 +02:00
$(pass /github/name)
82413499f5 add: Admin creation 2024-09-06 09:26:15 +02:00
$(pass /github/name)
df82782ba5 add: auth_test cases & null attack test 2024-09-06 09:22:37 +02:00
$(pass /github/name)
b42713b108 Del old sql schema 2024-09-06 09:21:50 +02:00
$(pass /github/name)
0d7c5674da chg testcases to be private 2024-09-06 09:21:30 +02:00
$(pass /github/name)
46e09a1285 moved admin mail config location 2024-09-06 09:20:55 +02:00
$(pass /github/name)
0aa332c26b adapted template to new routing 2024-09-05 16:40:46 +02:00
$(pass /github/name)
600cd83a81 add: user_service GetUserByID Function 2024-09-05 16:40:23 +02:00
$(pass /github/name)
580b1523f9 chg: routing and added auth to backend endpoint 2024-09-05 16:39:47 +02:00
$(pass /github/name)
8113b02356 chg: auth handling to jwt cookies 2024-09-05 16:39:06 +02:00
$(pass /github/name)
4e5e0963c7 add: better logging in user controller and tests 2024-09-05 16:38:23 +02:00
184 changed files with 19031 additions and 2339 deletions

6
.gitignore vendored
View File

@@ -14,7 +14,6 @@
# Output of the go coverage tool, specifically when used with LiteIDE # Output of the go coverage tool, specifically when used with LiteIDE
*.out *.out
# Dependency directories (remove the comment below to include it) # Dependency directories (remove the comment below to include it)
# vendor/ # vendor/
@@ -40,14 +39,13 @@ go.work
!*.css !*.css
!go.sum !go.sum
!go.mod !go.mod
!*.sql #!*.sql
!README.md !README.md
!LICENSE !LICENSE
# all template files: # all template files:
!*.template* !*.template*
!frontend/*
# Docker stuff # Docker stuff
!compose.yml !compose.yml
!Dockerfile !Dockerfile

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

37
config.json.template Normal file
View File

@@ -0,0 +1,37 @@
{
"db": {
"Path": "data/db.sqlite3"
},
"site": {
"AllowOrigins": "http://localhost:5173,http://localhost:3000",
"WebsiteTitle": "GoMembership",
"BaseUrl": "http://localhost:3000"
},
"auth": {
"APIKey": "CHANGE_THIS"
},
"smtp": {
"Host": "smtp.example.com",
"User": "user@example.com",
"Password": "CHANGE_THIS",
"Port": 465
},
"templates": {
"MailPath": "templates/email",
"HTMLPath": "templates/html",
"StaticPath": "templates/css",
"LogoURI": "/static/logo.png"
},
"recipients": {
"ContactForm": "contact@example.com",
"UserRegistration": "admin@example.com",
"AdminEmail": "admin@example.com"
},
"security": {
"RateLimits": {
"Limit": 1,
"Burst": 60
}
},
"Environment": "development"
}

2
frontend/.env.template Normal file
View File

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

39
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
test-results
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.template
!.env.test
!.npmrc # NPM configuration
!.prettierrc # Prettier configuration
!.prettierignore # Prettier ignore rules
!eslint.config.js # ESLint configuration
!jsconfig.json # JavaScript configuration
!package.json # Project dependencies and scripts
!package-lock.json # Lock file for exact dependency versions
!playwright.config.js # Playwright test configuration
!README.md # Project documentation
!svelte.config.js # Svelte configuration
# Vite
!vite.config.js # Vite configuration
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
!src/**

1
frontend/.npmrc Normal file
View File

@@ -0,0 +1 @@
engine-strict=true

4
frontend/.prettierignore Normal file
View File

@@ -0,0 +1,4 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock

15
frontend/.prettierrc Normal file
View File

@@ -0,0 +1,15 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}

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"]

38
frontend/README.md Normal file
View File

@@ -0,0 +1,38 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```bash
# create a new project in the current directory
npx sv create
# create a new project in my-app
npx sv create my-app
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```bash
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```bash
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.

24
frontend/eslint.config.js Normal file
View File

@@ -0,0 +1,24 @@
import prettier from 'eslint-config-prettier';
import js from '@eslint/js';
import { includeIgnoreFile } from '@eslint/compat';
import svelte from 'eslint-plugin-svelte';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
/** @type {import('eslint').Linter.Config[]} */
export default [
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...svelte.configs['flat/recommended'],
prettier,
...svelte.configs['flat/prettier'],
{
languageOptions: {
globals: {
...globals.browser,
...globals.node
}
}
}
];

19
frontend/jsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

4278
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
frontend/package.json Normal file
View File

@@ -0,0 +1,42 @@
{
"name": "frontend.new",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test:unit": "vitest",
"test": "npm run test:unit -- --run && npm run test:e2e",
"test:e2e": "playwright test"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0",
"@playwright/test": "^1.49.1",
"@sveltejs/adapter-auto": "^4.0.0",
"@sveltejs/adapter-node": "^5.2.12",
"@sveltejs/kit": "^2.16.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^2.46.1",
"globals": "^15.14.0",
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0",
"vite": "^6.0.0",
"vitest": "^3.0.0"
},
"dependencies": {
"svelte-i18n": "^4.0.1"
}
}

View File

@@ -0,0 +1,10 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
webServer: {
command: 'npm run build && npm run preview',
port: 4173
},
testDir: 'e2e'
});

137
frontend/src/app.d.ts vendored Normal file
View File

@@ -0,0 +1,137 @@
// See https://kit.svelte.dev/docs/types#app
interface Subscription {
id: number | -1;
name: string | '';
details: string | '';
conditions: string | '';
monthly_fee: number | 0;
hourly_rate: number | 0;
included_hours_per_year: number | 0;
included_hours_per_month: number | 0;
}
interface Membership {
id: number | -1;
status: number | -1;
start_date: string | '';
end_date: string | '';
parent_member_id: number | -1;
subscription: Subscription;
}
interface BankAccount {
id: number | -1;
mandate_date_signed: string | '';
bank: string | '';
account_holder_name: string | '';
iban: string | '';
bic: string | '';
mandate_reference: string | '';
}
interface Licence {
id: number | -1;
status: number | -1;
number: string | '';
issued_date: string | '';
expiration_date: string | '';
country: string | '';
categories: LicenceCategory[];
}
interface LicenceCategory {
id: number | -1;
category: string | '';
}
interface User {
email: string | '';
first_name: string | '';
last_name: string | '';
password: string | '';
phone: string | '';
address: string | '';
zip_code: string | '';
city: string | '';
status: number | -1;
id: number | -1;
role_id: number | -1;
dateofbirth: string | '';
company: string | '';
membership: Membership | null;
bank_account: BankAccount | null;
licence: Licence | null;
notes: string | '';
}
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 | '';
}
declare global {
namespace App {
// interface Error {}
interface Locals {
user: User;
users: User[];
cars: Cars[];
subscriptions: Subscription[];
licence_categories: LicenceCategory[];
}
interface Types {
licenceCategory: LicenceCategory;
subscription: Subscription;
membership: Membership;
licence: Licence;
licenceCategory: LicenceCategory;
bankAccount: BankAccount;
car: Car;
insurance: Insurance;
location: Location;
damage: Damage;
}
// interface PageData {}
// interface Platform {}
}
}
export {};

26
frontend/src/app.html Normal file
View File

@@ -0,0 +1,26 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Poiret+One&family=Quicksand:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"
/>
<!-- <link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css"
rel="stylesheet"
/> -->
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View File

@@ -0,0 +1,59 @@
import { BASE_API_URI } from '$lib/utils/constants.js';
import { refreshCookie, userDatesFromRFC3339 } from '$lib/utils/helpers';
/** @type {import('@sveltejs/kit').Handle} */
export async function handle({ event, resolve }) {
if (event.locals.user) {
// if there is already a user in session load page as normal
console.log('user is logged in');
return await resolve(event);
}
// get cookies from browser
const jwt = event.cookies.get('jwt');
if (!jwt) {
// if there is no jwt load page as normal
return await resolve(event);
}
const response = await fetch(`${BASE_API_URI}/auth/users/current`, {
credentials: 'include',
headers: {
Cookie: `jwt=${jwt}`
}
});
if (!response.ok) {
// Clear the invalid JWT cookie
event.cookies.delete('jwt', { path: '/' });
return await resolve(event);
}
const data = await response.json();
// Check if the server sent a new token
const newToken = response.headers.get('Set-Cookie');
refreshCookie(newToken, event.cookies);
userDatesFromRFC3339(data.user);
const [subscriptionsResponse, licenceCategoriesResponse] = await Promise.all([
fetch(`${BASE_API_URI}/auth/subscriptions`, {
credentials: 'include',
headers: { Cookie: `jwt=${jwt}` }
}),
fetch(`${BASE_API_URI}/auth/licence/categories`, {
credentials: 'include',
headers: { Cookie: `jwt=${jwt}` }
})
]);
const [subscriptionsData, licence_categoriesData] = await Promise.all([
subscriptionsResponse.json(),
licenceCategoriesResponse.json()
]);
event.locals.user = data.user;
event.locals.subscriptions = subscriptionsData.subscriptions;
event.locals.licence_categories = licence_categoriesData.licence_categories;
// load page as normal
return await resolve(event);
}

View File

@@ -0,0 +1,7 @@
import { describe, it, expect } from 'vitest';
describe('sum test', () => {
it('adds 1 + 2 to equal 3', () => {
expect(1 + 2).toBe(3);
});
});

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

@@ -0,0 +1,22 @@
<script>
// import Developer from "$lib/img/hero-image.png";
const year = new Date().getFullYear();
</script>
<footer class="footer-container">
<div class="footer-branding-container">
<div class="footer-branding">
<a class="footer-crafted-by-container" href="https://github.com/17Halbe">
<span>Developed by</span>
<!-- <img
class="footer-branded-crafted-img"
src={Developer}
alt="Alexander Stölting"
/> -->
</a>
<span class="footer-copyright">&copy; {year} Alexander Stölting. All Rights Reserved.</span>
</div>
</div>
</footer>

View File

@@ -0,0 +1,398 @@
<script>
import { onMount, onDestroy } from 'svelte';
import { applyAction, enhance } from '$app/forms';
import { base } from '$app/paths';
import { page } from '$app/stores';
import { t } from 'svelte-i18n';
import { writable } from 'svelte/store';
import { PERMISSIONS } from '$lib/utils/constants';
import { hasPrivilige } from '$lib/utils/helpers';
let isMobileMenuOpen = false;
/** @type{HTMLDivElement} */
let headerContainer;
onMount(() => {
console.log('Page data in Header:', $page);
document.documentElement.setAttribute('data-theme', $theme);
document.addEventListener('click', handleClickOutside);
});
onDestroy(() => {
document.removeEventListener('click', handleClickOutside);
});
$: {
console.log('Page data updated:', $page);
}
const theme = writable(
typeof window !== 'undefined' ? localStorage.getItem('theme') || 'dark' : 'dark'
);
/**
* handle a click outside the menu to close it.
* @param {MouseEvent} event
*/
function handleClickOutside(event) {
if (
isMobileMenuOpen &&
event.target instanceof Node &&
!!headerContainer.contains(event.target)
) {
isMobileMenuOpen = false;
}
}
function toggleMobileMenu() {
isMobileMenuOpen = !isMobileMenuOpen;
}
function toggleTheme() {
theme.update((current) => {
const newTheme = current === 'dark' ? 'bright' : 'dark';
localStorage.setItem('theme', newTheme);
document.documentElement.setAttribute('data-theme', newTheme);
return newTheme;
});
}
</script>
<header class="header">
<div class="header-container" bind:this={headerContainer}>
<div class="header-left">
<div class="header-crafted-by-container">
<!-- <a href="https://tiny-bits.net/">
<span>Developed by</span><img
src={Developer}
alt="Alexander Stölting"
/> -->
<!-- </a> -->
</div>
</div>
<div class="mobile-menu-container">
<button
class="mobile-menu-toggle"
on:click={toggleMobileMenu}
aria-label="Toggle menu"
aria-expanded={isMobileMenuOpen}
>
<i class="fas {isMobileMenuOpen ? 'fa-times' : 'fa-bars'}"></i>
</button>
</div>
<div class="header-right" class:mobile-menu-open={isMobileMenuOpen}>
<div class="header-nav-item" class:active={$page.url.pathname === '/'}>
<a href={`${base}/`}>home</a>
</div>
{#if !$page.data.user}
<div class="header-nav-item" class:active={$page.url.pathname === `${base}/auth/login`}>
<a href={`${base}/auth/login`}>login</a>
</div>
<!-- <div
class="header-nav-item"
class:active={$page.url.pathname === "/auth/register"}
>
<a href="/auth/register">register</a>
</div> -->
{:else}
<div class="header-nav-item">
<a href={`${base}/auth/about/${$page.data.user.id}`}>
<!-- <img
src={$page.data.user.profile_picture ? $page.data.user.profile_picture : Avatar}
alt={`${$page.data.user.first_name} ${$page.data.user.last_name}`}
/> -->
{$page.data.user.first_name}
{$page.data.user.last_name}
</a>
</div>
{#if hasPrivilige($page.data.user, PERMISSIONS.View)}
<div
class="header-nav-item"
class:active={$page.url.pathname.startsWith(`${base}/auth/admin/users`)}
>
<a href={`${base}/auth/admin/users`}>{$t('user.management')}</a>
</div>
{/if}
<!-- {#if $page.data.user.is_superuser}
<div
class="header-nav-item"
class:active={$page.url.pathname.startsWith("/auth/admin")}
>
<a href="/auth/admin">admin</a>
</div>
{/if} -->
<form
class="header-nav-item"
action={`${base}/auth/logout`}
method="POST"
use:enhance={async () => {
return async ({ result }) => {
await applyAction(result);
};
}}
>
<button type="submit">logout</button>
</form>
{/if}
<div class="theme-toggle">
<label class="switch">
<input type="checkbox" checked={$theme === 'bright'} on:change={toggleTheme} />
<span class="slider">
<i class="fas fa-sun"></i>
<i class="fas fa-moon"></i>
</span>
</label>
</div>
</div>
</div>
</header>
<style>
.theme-toggle {
display: flex;
align-items: center;
margin-left: 20px;
}
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 30px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--surface0);
transition: 0.4s;
border-radius: 30px;
padding: 4px;
}
.slider:before {
position: absolute;
content: '';
height: 22px;
width: 22px;
left: 4px;
bottom: 4px;
background-color: var(--text);
transition: 0.4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--surface0);
}
input:checked + .slider:before {
transform: translateX(30px);
}
.fa-sun,
.fa-moon {
position: absolute;
font-size: 16px;
top: 7px;
color: var(--text);
transition: 0.4s;
}
.fa-sun {
left: 7px;
opacity: 0;
}
.fa-moon {
right: 7px;
opacity: 1;
}
input:checked + .slider .fa-sun {
opacity: 1;
}
input:checked + .slider .fa-moon {
opacity: 0;
}
.mobile-menu-toggle {
display: none;
background: none;
border: none;
color: var(--text);
font-size: 1.5rem;
cursor: pointer;
padding: 0.5rem;
}
.mobile-menu-container {
display: none;
}
.header {
position: fixed;
top: 0;
left: 0;
z-index: 1;
box-sizing: border-box;
width: 100%;
padding: 3em 0 0;
background: var(--base);
}
.header .header-container {
width: 100%;
max-width: calc(1200px + 10em);
height: 5em;
display: flex;
flex-direction: column;
align-items: center;
margin: 0 auto;
background-color: var(--base);
}
.header .header-container .header-left {
display: flex;
flex-grow: 1;
}
.header .header-container .header-left .header-crafted-by-container {
font-size: 18px;
font-weight: 300;
}
.header .header-container .header-right {
flex-grow: 1;
justify-content: space-between;
letter-spacing: 1px;
font-weight: 500;
}
.header .header-container .header-right .header-nav-item {
text-transform: uppercase;
margin-left: 10px;
}
.header .header-container .header-right .header-nav-item button {
all: unset;
cursor: pointer;
}
.header .header-container .header-right .header-nav-item a,
.header .header-container .header-right .header-nav-item button {
transition: color 0.3s ease-in-out;
display: block;
padding: 20px 0;
border: none;
color: var(--subtext0);
}
.header .header-container .header-right .header-nav-item:hover a,
.header .header-container .header-right .header-nav-item:hover button {
color: var(--lavender);
}
@media (min-width: 768px) {
.header {
padding: 3em 5rem 0;
}
.header .header-container {
flex-direction: row;
}
.header .header-container .header-right {
display: flex;
justify-content: flex-end;
}
.header .header-container .header-right .header-nav-item {
margin-left: 26px;
}
}
@media (max-width: 768px) {
.mobile-menu-container {
display: block;
position: relative;
}
.header {
padding: 1rem 0;
}
.mobile-menu-toggle {
display: block;
position: absolute;
top: 1rem;
right: 1rem;
z-index: 2;
}
.header .header-container {
z-index: 1;
position: relative;
padding: 0 1rem;
flex-direction: column;
align-items: stretch;
height: auto;
padding: 1rem;
}
.header-right {
display: none;
top: 4rem;
left: 0;
right: 0;
background: var(--base);
padding: 1rem;
border-bottom: 1px solid var(--surface1);
}
.header-right.mobile-menu-open {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.header-nav-item {
width: 100%;
text-align: center;
margin: 0;
padding: 0.5rem 0;
border-bottom: 1px solid var(--surface1);
}
.header-nav-item:last-child {
border-bottom: none;
}
.theme-toggle {
margin: 1rem 0;
}
.header .header-container .header-right {
flex-direction: column;
align-items: stretch;
}
.header .header-container .header-right .header-nav-item {
margin: 0;
text-align: center;
border-top: 1px solid var(--surface1);
}
.header .header-container .header-right .header-nav-item a,
.header .header-container .header-right .header-nav-item button {
padding: 1rem 0;
}
.theme-toggle {
display: flex;
justify-content: center;
margin: 1rem 0;
}
}
</style>

View File

@@ -0,0 +1,117 @@
<script>
// @ts-nocheck
export let avatar;
export let fieldName;
export let title;
let newAvatar;
const onFileSelected = (e) => {
const target = e.target;
if (target && target.files) {
let reader = new FileReader();
reader.readAsDataURL(target.files[0]);
reader.onload = (e) => {
newAvatar = e.target?.result;
};
}
};
</script>
<div id="app">
{#if avatar}
<img class="avatar" src={avatar} alt="d" />
{:else}
<img
class="avatar"
src={newAvatar
? newAvatar
: "https://cdn4.iconfinder.com/data/icons/small-n-flat/24/user-alt-512.png"}
alt=""
/>
<input
type="file"
id="file"
name={fieldName}
required
on:change={(e) => onFileSelected(e)}
/>
<label for="file" class="btn-3">
{#if newAvatar}
<span>Bild ausgewählt, clicke Hochladen.</span>
{:else}
<span>{title}</span>
{/if}
</label>
{/if}
</div>
<style>
#app {
margin-top: 1rem;
display: flex;
align-items: center;
justify-content: center;
flex-flow: column;
color: rgb(148 163 184);
}
.avatar {
display: flex;
width: 8rem;
}
[type="file"] {
height: 0;
overflow: hidden;
width: 0;
}
[type="file"] + label {
background: #9b9b9b;
border: none;
border-radius: 5px;
color: #fff;
cursor: pointer;
display: inline-block;
font-weight: 500;
margin-bottom: 1rem;
outline: none;
padding: 1rem 50px;
position: relative;
transition: all 0.3s;
vertical-align: middle;
}
[type="file"] + label:hover {
background-color: #9b9b9b;
}
[type="file"] + label.btn-3 {
background-color: #d43aff;
border-radius: 0;
overflow: hidden;
}
[type="file"] + label.btn-3 span {
display: inline-block;
height: 100%;
transition: all 0.3s;
width: 100%;
}
[type="file"] + label.btn-3::before {
color: #fff;
content: "\01F4F7";
font-size: 200%;
height: 100%;
left: 45%;
position: absolute;
top: -180%;
transition: all 0.3s;
width: 100%;
}
[type="file"] + label.btn-3:hover {
background-color: rgba(14, 166, 236, 0.5);
}
[type="file"] + label.btn-3:hover span {
transform: translateY(300%);
}
[type="file"] + label.btn-3:hover::before {
top: 0;
}
</style>

View File

@@ -0,0 +1,343 @@
<script>
import { t } from 'svelte-i18n';
/** @type {string} */
export let name;
/** @type {string} */
export let type = 'text';
/** @type {string|Number|null} */
export let value;
/** @type {string} */
export let placeholder = '';
/** @type {Number} */
export let rows = 4;
/** @type {Array<{value: string | number, label: string, color?:string}>} */
export let options = [];
/** @type {Boolean} */
export let required = false;
/** @type {string} */
export let label = '';
/** @type {string} */
export let otherPasswordValue = '';
/** @type {boolean} */
export let toUpperCase = false;
/** @type {boolean} */
export let checked = false;
/** @type {boolean} */
export let readonly = false;
/** @type {string} */
export let backgroundColor = '--surface0';
/**
* @param {Event} event - The input event
*/
function handleInput(event) {
const target = event.target;
if (target instanceof HTMLInputElement) {
let inputValue = target.value;
if (toUpperCase) {
inputValue = inputValue.toUpperCase();
}
target.value = inputValue; // Update the input field value
value = inputValue.trim();
}
}
/**
* Validates the field
* @param {string} name - The name of the field
* @param {string|Number|null} value - The value of the field
* @param {Boolean} required - The requirements of the field
* @returns {string|null} The error message or null if valid
*/
function validateField(name, value, required) {
if (value === null || (typeof value === 'string' && !value.trim() && !required)) return null;
if (name.includes('membership_start_date')) {
return typeof value === 'string' && value.trim() ? null : $t('validation.date');
} else if (name.includes('email')) {
return typeof value === 'string' && /^\S+@\S+\.\S+$/.test(value)
? null
: $t('validation.email');
} else if (name.includes('password')) {
if (typeof value === 'string' && value.length < 8) {
return $t('validation.password');
}
if (otherPasswordValue && value !== otherPasswordValue) {
return $t('validation.password_match');
}
return null;
} else if (name.includes('phone')) {
return typeof value === 'string' && /^\+?[0-9\s()-]{7,}$/.test(value)
? null
: $t('validation.phone');
} else if (name.includes('zip_code')) {
return typeof value === 'string' && /^\d{5}$/.test(value) ? null : $t('validation.zip_code');
} else if (name.includes('iban')) {
return typeof value === 'string' && /^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(value)
? null
: $t('validation.iban');
} else if (name.includes('bic')) {
return typeof value === 'string' && /^[A-Z]{6}[A-Z2-9][A-NP-Z0-9]([A-Z0-9]{3})?$/.test(value)
? null
: $t('validation.bic');
} else if (name.includes('licence_number')) {
return typeof value === 'string' && value.length == 11 ? null : $t('validation.licence');
} else {
return typeof value === 'string' && !value.trim() && required
? $t('validation.required')
: null;
}
}
$: error = validateField(name, value, required);
$: selectedOption = options.find((option) => option.value == value);
$: selectedColor = selectedOption ? `var(${selectedOption.color})` : '';
</script>
<div
class="input-box {type === 'checkbox' ? 'checkbox-container' : ''}"
style="background-color: var({backgroundColor});"
>
{#if type === 'checkbox'}
<label class="form-control {readonly ? 'form-control--disabled' : ''}">
<input
type="checkbox"
{name}
{value}
{checked}
{readonly}
on:change={() => (checked = !checked)}
/>
<span class="checkbox-text"> {label} </span>
</label>
{:else}
<span class="label">{label}</span>
{/if}
<div class="input-error-container">
{#if error}
<span class="error-message">{error}</span>
{/if}
{#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
{name}
bind:value
{required}
class="input select"
style={selectedColor ? `color: ${selectedColor};` : ''}
disabled={readonly}
>
{#each options as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
{:else if type === 'textarea'}
<textarea
{name}
{placeholder}
{required}
{value}
{readonly}
{rows}
class="input textarea {readonly ? 'readonly' : ''}"
style="height:{rows * 1.5}em;"
></textarea>
{:else if type != 'checkbox'}
<input
{name}
{type}
{placeholder}
{readonly}
{value}
{required}
on:input={handleInput}
on:blur={handleInput}
class="input {readonly ? 'readonly' : ''}"
/>
{/if}
</div>
</div>
<style>
:root {
--form-control-color: var(--green); /* Changed from #6bff55 */
--form-control-disabled: var(--subtext1); /* Changed from #959495 */
}
.form-control {
font-size: 1rem;
font-weight: bold;
line-height: 1.1;
display: grid;
grid-template-columns: 1.5em auto;
gap: 0.75em;
align-items: center;
opacity: 0.8;
color: var(--text);
}
.form-control--disabled {
color: var(--form-control-disabled);
cursor: not-allowed;
}
input[type='checkbox'] {
-webkit-appearance: none;
appearance: none;
background-color: var(--surface0);
margin: 0;
font: inherit;
color: var(--text);
width: 1.75em;
height: 1.75em;
border: 0.15em solid var(--overlay0);
border-radius: 0.5em;
transform: translateY(-0.075em);
display: grid;
place-content: center;
transition: transform 0.2s ease-in-out;
}
input[type='checkbox']::before {
content: '';
width: 1em;
height: 1em;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
transform: scale(0);
transform-origin: bottom left;
transition: 120ms transform ease-in-out;
box-shadow: inset 1em 1em var(--form-control-color);
background-color: var(--crust);
}
input[type='checkbox']:checked::before {
transform: scale(1);
}
input[type='checkbox']:hover {
outline: max(2px, 0.15em) solid var(--lavender);
outline-offset: max(2px, 0.15em);
transform: scale(1.3);
}
input[type='checkbox']:disabled {
--form-control-color: var(--form-control-disabled);
color: var(--form-control-disabled);
cursor: not-allowed;
}
.readonly {
background-color: var(--surface0);
cursor: not-allowed;
opacity: 0.7;
color: var(--overlay1);
}
.checkbox-container {
display: inline-flex;
align-items: center;
background-color: transparent;
margin: 0.5rem 0;
}
.checkbox-text {
font-size: 16px;
color: var(--text);
}
@media (min-width: 768px) {
.checkbox-text {
flex-direction: row;
align-items: center;
}
}
.select {
padding-right: 1.5em;
background-color: var(--surface0);
font-weight: bold;
}
.input-error-container {
display: flex;
flex-direction: column;
gap: 0.5rem;
width: 100%;
max-width: 444px;
}
.error-message {
color: var(--red); /* Changed from #eb5424 */
font-size: 12px;
margin-bottom: 5px;
align-self: flex-start;
}
.input {
width: 100%;
}
input,
textarea,
select {
width: 100%;
padding: 0.75rem 0;
background-color: var(--surface0);
border: 1px solid var(--overlay0);
border-radius: 6px;
color: var(--text);
transition: border-color 0.2s ease-in-out;
}
input:focus,
textarea:focus,
select:focus {
outline: none;
border-color: var(--lavender);
}
input:hover:not(:disabled),
textarea:hover:not(:disabled),
select:hover:not(:disabled) {
border-color: var(--overlay2);
}
textarea {
resize: vertical;
min-height: 100px;
}
/* Add consistent spacing between input boxes */
.input-box {
padding: 0.5rem;
background-color: var(--surface0);
border-radius: 6px;
}
.input-box .label {
display: block;
margin-bottom: 0.5rem;
color: var(--lavender);
font-weight: 500;
}
/* Style select dropdown */
select option {
background-color: var(--base);
color: var(--text);
padding: 0.5rem;
}
</style>

View File

@@ -0,0 +1,93 @@
<script>
import { quintOut } from 'svelte/easing';
const modal = (/** @type {Element} */ node, { duration = 300 } = {}) => {
const transform = getComputedStyle(node).transform;
return {
duration,
easing: quintOut,
css: (/** @type {any} */ t, /** @type {number} */ u) => {
return `transform:
${transform}
scale(${t})
translateY(${u * -100}%)
`;
}
};
};
</script>
<div class="modal-background">
<div transition:modal|global={{ duration: 1000 }} class="modal" role="dialog" aria-modal="true">
<div class="container">
<slot />
</div>
</div>
</div>
<style>
.modal-background {
width: 100%;
height: 100%;
position: fixed;
top: 0;
right: 0;
bottom: 0;
background: var(--modal-backdrop); /* var(--base) with 0.75 opacity */
backdrop-filter: blur(4px); /* Optional: adds a slight blur effect */
z-index: 9999;
display: flex;
}
.modal {
position: relative;
left: 50%;
top: 50%;
width: 70%;
background-color: var(--base);
border: 1px solid var(--surface0);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(17, 17, 27, 0.5); /* var(--crust) with opacity */
transform: translate(-50%, -50%);
}
@media (max-width: 990px) {
.modal {
width: 90%;
}
}
.modal .container {
max-height: 90vh;
overflow-y: auto;
align-items: center;
padding: 2rem;
background-color: var(--base);
border-radius: 8px;
}
/* Scrollbar styling */
.modal .container::-webkit-scrollbar {
width: 8px;
}
.modal .container::-webkit-scrollbar-track {
background: var(--surface0);
border-radius: 4px;
}
.modal .container::-webkit-scrollbar-thumb {
background: var(--surface2);
border-radius: 4px;
}
.modal .container::-webkit-scrollbar-thumb:hover {
background: var(--surface1);
}
@media (min-width: 680px) {
.modal .container {
flex-direction: column;
left: 0;
}
}
</style>

View File

@@ -0,0 +1,24 @@
<script>
/** @type {number | null} */
export let width;
/** @type {string | null} */
export let message;
</script>
<div class="loading">
<p class="simple-loader" style={width ? `width: ${width}px` : ''}></p>
{#if message}
<p>{message}</p>
{/if}
</div>
<style>
.loading {
display: flex;
align-items: center;
justify-content: center;
}
.loading p {
margin-left: 0.5rem;
}
</style>

View File

@@ -0,0 +1,168 @@
<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 { receive, send } from '$lib/utils/helpers';
import { t } from 'svelte-i18n';
import { defaultSubscription } from '$lib/utils/defaults';
const dispatch = createEventDispatcher();
/** @type {import('../../routes/auth/about/[id]/$types').ActionData} */
export let form;
/** @type {App.Locals['user'] } */
export let user;
/** @type {App.Types['subscription'] | null} */
export let subscription;
console.log('Opening subscription modal with:', subscription);
$: subscription = subscription || { ...defaultSubscription() };
$: isLoading = subscription === undefined || user === undefined;
let isUpdating = false;
/** @type {import('../../routes/auth/about/[id]/$types').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.subscription_data')} />
{:else if user && subscription}
<form class="content" action="?/updateSubscription" method="POST" use:enhance={handleUpdate}>
<input name="susbscription[id]" type="hidden" bind:value={subscription.id} />
<h1 class="step-title" style="text-align: center;">
{subscription.id ? $t('subscriptions.edit') : $t('subscriptions.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="tab-content" style="display: block">
<InputField
name="subscription[name]"
label={$t('subscriptions.name')}
bind:value={subscription.name}
placeholder={$t('placeholder.subscription_name')}
required={true}
readonly={subscription.id > 0}
/>
<InputField
name="subscription[details]"
label={$t('details')}
type="textarea"
bind:value={subscription.details}
placeholder={$t('placeholder.subscription_details')}
required={true}
/>
<InputField
name="subscription[conditions]"
type="textarea"
label={$t('subscriptions.conditions')}
bind:value={subscription.conditions}
placeholder={$t('placeholder.subscription_conditions')}
readonly={subscription.id > 0}
/>
<InputField
name="subscription[monthly_fee]"
type="number"
label={$t('subscriptions.monthly_fee')}
bind:value={subscription.monthly_fee}
placeholder={$t('placeholder.subscription_monthly_fee')}
required={true}
readonly={subscription.id > 0}
/>
<InputField
name="subscription[hourly_rate]"
type="number"
label={$t('subscriptions.hourly_rate')}
bind:value={subscription.hourly_rate}
required={true}
readonly={subscription.id > 0}
/>
<InputField
name="subscription[included_hours_per_year]"
type="number"
label={$t('subscriptions.included_hours_per_year')}
bind:value={subscription.included_hours_per_year}
readonly={subscription.id > 0}
/>
<InputField
name="included_hours_per_month"
type="number"
label={$t('subscriptions.included_hours_per_month')}
bind:value={subscription.included_hours_per_month}
readonly={subscription.id > 0}
/>
</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}
<style>
.tab-content {
padding: 1rem;
border-radius: 0 0 3px 3px;
background-color: var(--surface0);
border: 1px solid var(--surface1);
margin-top: 1rem;
}
.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

@@ -0,0 +1,14 @@
<script>
import { slide } from "svelte/transition";
/** @type {string} */
export let key;
/** @type {number} */
export let duration = 300;
</script>
{#key key}
<div in:slide|global={{ duration, delay: duration }} out:slide|global={{ duration }}>
<slot />
</div>
{/key}

View File

@@ -0,0 +1,659 @@
<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 { PERMISSIONS } from '$lib/utils/constants';
// import { defaultBankAccount, defaultLicence, defaultMembership } from '$lib/utils/defaults';
/** @type {import('../../routes/auth/about/[id]/$types').ActionData} */
export let form;
/** @type {App.Locals['subscriptions'] | null}*/
export let subscriptions;
/** @type {App.Locals['user']} */
export let user;
export let submit_form = true;
// 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']} */
export let editor;
let readonlyUser = !hasPrivilige(editor, PERMISSIONS.Update);
// $: isNewUser = user === null;
$: isLoading = user === undefined;
$: if (user != null) {
console.log(user);
}
/** @type {App.Locals['licence_categories'] | null} */
export let licence_categories;
const userStatusOptions = [
{ value: 1, label: $t('userStatus.1'), color: '--subtext1' }, // Grey for "Nicht verifiziert"
{ value: 2, label: $t('userStatus.2'), color: '--light-green' }, // Light green for "Verifiziert"
{ value: 3, label: $t('userStatus.3'), color: '--green' }, // Green for "Aktiv"
{ value: 4, label: $t('userStatus.4'), color: '--pink' }, // Pink for "Passiv"
{ value: 5, label: $t('userStatus.5'), color: '--red' } // Red for "Deaktiviert"
];
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: 1, label: $t('userRole.1'), color: '--light-green' }, // Light green for "Verifiziert"
{ value: 2, label: $t('userRole.2'), color: '--green' }, // Light green for "Verifiziert"
{ value: 4, label: $t('userRole.4'), color: '--pink' }, // Green for "Aktiv"
{ value: 8, label: $t('userRole.8'), color: '--red' } // Pink for "Passiv"
];
const membershipStatusOptions = [
{ value: 3, label: $t('userStatus.3'), color: '--green' }, // Green for "Aktiv"
{ value: 4, label: $t('userStatus.4'), color: '--pink' }, // Pink for "Passiv"
{ value: 5, label: $t('userStatus.5'), color: '--red' } // Red for "Deaktiviert"
];
const licenceStatusOptions = [
{ value: 1, label: $t('userStatus.1'), color: '--subtext1' }, // Grey for "Nicht verifiziert"
{ value: 3, label: $t('userStatus.3'), color: '--green' }, // Green for "Aktiv"
{ value: 4, label: $t('userStatus.4'), color: '--pink' }, // Pink for "Passiv"
{ value: 5, label: $t('userStatus.5'), color: '--red' } // Red for "Deaktiviert"
];
const dispatch = createEventDispatcher();
/** @type { (keyof user)[] } */
const TABS = ['membership', 'licence', 'bank_account'];
let activeTab = 'profile';
let isUpdating = false,
password = '',
confirm_password = '';
/** @type {Object.<string, App.Locals['licence_categories']>} */
$: groupedCategories = licence_categories ? groupCategories(licence_categories) : {};
$: subscriptionOptions = subscriptions
? subscriptions.map((sub) => ({
value: sub?.name ?? '',
label: sub?.name ?? ''
}))
: [];
$: selectedSubscription = subscriptions
? subscriptions.find((sub) => sub?.name === user.membership?.subscription.name) || null
: null;
/**
* creates groups of categories depending on the first letter
* @param {App.Locals['licence_categories']} categories - the categories to sort and group
* @returns {Object.<string, App.Locals['licence_categories']>} Grouped categories
*/
function groupCategories(categories) {
return Object.entries(categories)
.sort((a, b) => a[1].category.localeCompare(b[1].category))
.reduce(
(/** @type {Object.<string, App.Locals['licence_categories']>} */ acc, [, category]) => {
const firstLetter = category.category[0];
if (!acc[firstLetter]) {
acc[firstLetter] = [];
}
acc[firstLetter].push(category);
return acc;
},
{}
);
}
/** @type {import('@sveltejs/kit').SubmitFunction} */
const handleUpdate = ({ cancel }) => {
if (!submit_form) {
cancel();
dispatch('close');
return;
}
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' });
}
console.log('submitting');
return submit_form ? await applyAction(result) : undefined;
};
};
</script>
{#if isLoading}
<SmallLoader width={30} message={$t('loading.user_data')} />
{:else if user}
<form
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;">
{user.id ? $t('user.edit') : $t('user.create')}
</h1>
{#if form?.success}
<h4
class="step-subtitle warning"
in:receive|global={{ key: Math.floor(Math.random() * 100) }}
out:send|global={{ key: Math.floor(Math.random() * 100) }}
>
Um einen fehlerhaften upload Ihres Bildes zu vermeiden, clicke bitte auf den "Update" Button
unten.
</h4>
{/if}
{#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">
<button
type="button"
class="button-dark"
class:active={activeTab === 'profile'}
on:click={() => (activeTab = 'profile')}
>
{$t('profile')}
</button>
{#each TABS as tab}
{#if user[tab] != null}
<button
type="button"
class="button-dark"
class:active={activeTab === tab}
on:click={() => (activeTab = tab)}
>
{$t('user.' + tab)}
</button>
{/if}
{/each}
</div>
<div class="tab-content" style="display: {activeTab === 'profile' ? 'block' : 'none'}">
{#if hasPrivilige(user, PERMISSIONS.Member)}
<InputField
name="user[status]"
type="select"
label={$t('status')}
bind:value={user.status}
options={userStatusOptions}
readonly={readonlyUser}
/>
{/if}
{#if hasPrivilige(editor, PERMISSIONS.Super)}
<InputField
name="user[role_id]"
type="select"
label={$t('user.role')}
bind:value={user.role_id}
options={userRoleOptions}
/>
{/if}
{#if hasPrivilige(user, PERMISSIONS.Member)}
<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}
/>
{/if}
<InputField
name="user[first_name]"
label={$t('user.first_name')}
bind:value={user.first_name}
placeholder={$t('placeholder.first_name')}
required={true}
readonly={readonlyUser}
/>
<InputField
name="user[last_name]"
label={$t('user.last_name')}
bind:value={user.last_name}
placeholder={$t('placeholder.last_name')}
required={true}
readonly={readonlyUser}
/>
<InputField
name="user[company]"
label={$t('company')}
bind:value={user.company}
placeholder={$t('placeholder.company')}
/>
<InputField
name="user[email]"
type="email"
label={$t('user.email')}
bind:value={user.email}
placeholder={$t('placeholder.email')}
required={true}
/>
<InputField
name="user[phone]"
type="tel"
label={$t('user.phone')}
bind:value={user.phone}
placeholder={$t('placeholder.phone')}
/>
<InputField
name="user[dateofbirth]"
type="date"
label={$t('user.dateofbirth')}
bind:value={user.dateofbirth}
placeholder={$t('placeholder.dateofbirth')}
readonly={readonlyUser}
/>
<InputField
name="user[address]"
label={$t('address')}
bind:value={user.address}
placeholder={$t('placeholder.address')}
/>
<InputField
name="user[zip_code]"
label={$t('zip_code')}
bind:value={user.zip_code}
placeholder={$t('placeholder.zip_code')}
/>
<InputField
name="user[city]"
label={$t('city')}
bind:value={user.city}
placeholder={$t('placeholder.city')}
/>
{#if !readonlyUser}
<InputField
name="user[notes]"
type="textarea"
label={$t('notes')}
bind:value={user.notes}
placeholder={$t('placeholder.notes', {
values: { name: user.first_name || '' }
})}
rows={10}
/>
{/if}
</div>
{#if hasPrivilige(user, PERMISSIONS.Member) && user.licence}
<div class="tab-content" style="display: {activeTab === 'licence' ? 'block' : 'none'}">
<InputField
name="user[licence][status]"
type="select"
label={$t('status')}
bind:value={user.licence.status}
options={licenceStatusOptions}
readonly={readonlyUser}
/>
<InputField
name="user[licence][number]"
type="text"
label={$t('licence_number')}
bind:value={user.licence.number}
placeholder={$t('placeholder.licence_number')}
toUpperCase={true}
readonly={readonlyUser}
/>
<InputField
name="user[licence][issued_date]"
type="date"
label={$t('issued_date')}
bind:value={user.licence.issued_date}
placeholder={$t('placeholder.issued_date')}
readonly={readonlyUser}
/>
<InputField
name="user[licence][expiration_date]"
type="date"
label={$t('expiration_date')}
bind:value={user.licence.expiration_date}
placeholder={$t('placeholder.expiration_date')}
readonly={readonlyUser}
/>
<InputField
name="user[licence][country]"
label={$t('country')}
bind:value={user.licence.country}
placeholder={$t('placeholder.issuing_country')}
readonly={readonlyUser}
/>
<div class="licence-categories">
<h3>{$t('licence_categories')}</h3>
<div class="checkbox-grid">
{#each Object.entries(groupedCategories) as [, categories], groupIndex}
{#if groupIndex > 0}
<div class="category-break"></div>
{/if}
{#each categories as category}
<div class="checkbox-item">
<div class="checkbox-label-container">
<InputField
type="checkbox"
name="user[licence][categories][]"
value={JSON.stringify(category)}
label={category.category}
checked={user.licence.categories != null &&
user.licence.categories.some((cat) => cat.category === category.category)}
/>
</div>
<span class="checkbox-description">
{$t(`licenceCategory.${category.category}`)}
</span>
</div>
{/each}
{/each}
</div>
</div>
</div>
{/if}
{#if user.membership}
<div
class="tab-content"
style="display: {activeTab === 'membership' && subscriptions ? 'block' : 'none'}"
>
<InputField
name="user[membership][status]"
type="select"
label={$t('status')}
bind:value={user.membership.status}
options={membershipStatusOptions}
readonly={readonlyUser}
/>
<InputField
name="user[membership][subscription][name]"
type="select"
label={$t('subscriptions.subscription')}
bind:value={user.membership.subscription.name}
options={subscriptionOptions}
readonly={readonlyUser || !hasPrivilige(user, PERMISSIONS.Member)}
/>
<div class="subscription-info">
{#if hasPrivilige(user, PERMISSIONS.Member)}
<div class="subscription-column">
<p>
<strong>{$t('subscriptions.monthly_fee')}:</strong>
{selectedSubscription?.monthly_fee || '-'}
</p>
<p>
<strong>{$t('subscriptions.hourly_rate')}:</strong>
{selectedSubscription?.hourly_rate || '-'}
</p>
{#if selectedSubscription?.included_hours_per_year}
<p>
<strong>{$t('subscriptions.included_hours_per_year')}:</strong>
{selectedSubscription?.included_hours_per_year}
</p>
{/if}
{#if selectedSubscription?.included_hours_per_month}
<p>
<strong>{$t('subscriptions.included_hours_per_month')}:</strong>
{selectedSubscription?.included_hours_per_month}
</p>
{/if}
</div>
{/if}
<div class="subscription-column">
<p>
<strong>{$t('details')}:</strong>
{selectedSubscription?.details || '-'}
</p>
{#if selectedSubscription?.conditions}
<p>
<strong>{$t('subscriptions.conditions')}:</strong>
{selectedSubscription?.conditions}
</p>
{/if}
</div>
</div>
<InputField
name="user[membership][start_date]"
type="date"
label={$t('start')}
bind:value={user.membership.start_date}
placeholder={$t('placeholder.start_date')}
readonly={readonlyUser}
/>
<InputField
name="user[membership][end_date]"
type="date"
label={$t('end')}
bind:value={user.membership.end_date}
placeholder={$t('placeholder.end_date')}
readonly={readonlyUser}
/>
{#if hasPrivilige(user, PERMISSIONS.Member)}
<InputField
name="user[membership][parent_member_id]"
type="number"
label={$t('parent_member_id')}
bind:value={user.membership.parent_member_id}
placeholder={$t('placeholder.parent_member_id')}
readonly={readonlyUser}
/>
{/if}
</div>
{/if}
{#if user.bank_account}
<div class="tab-content" style="display: {activeTab === 'bank_account' ? 'block' : 'none'}">
<InputField
name="user[bank_account][account_holder_name]"
label={$t('bank_account_holder')}
bind:value={user.bank_account.account_holder_name}
placeholder={$t('placeholder.bank_account_holder')}
/>
<InputField
name="user[bank_account][bank_name]"
label={$t('bank_name')}
bind:value={user.bank_account.bank}
placeholder={$t('placeholder.bank_name')}
/>
<InputField
name="user[bank_account][iban]"
label={$t('iban')}
bind:value={user.bank_account.iban}
placeholder={$t('placeholder.iban')}
toUpperCase={true}
/>
<InputField
name="user[bank_account][bic]"
label={$t('bic')}
bind:value={user.bank_account.bic}
placeholder={$t('placeholder.bic')}
toUpperCase={true}
/>
<InputField
name="user[bank_account][mandate_reference]"
label={$t('mandate_reference')}
bind:value={user.bank_account.mandate_reference}
placeholder={$t('placeholder.mandate_reference')}
readonly={readonlyUser}
/>
<InputField
name="user[bank_account][mandate_date_signed]"
label={$t('mandate_date_signed')}
type="date"
bind:value={user.bank_account.mandate_date_signed}
readonly={true}
/>
</div>
{/if}
<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}
<style>
.category-break {
grid-column: 1 / -1;
height: 1px;
background-color: var(--overlay0);
margin-top: 10px;
margin-left: 20%;
width: 60%;
opacity: 0.4;
}
.licence-categories {
margin-bottom: 20px;
}
.checkbox-grid {
display: grid;
grid-template-columns: 1fr;
gap: 0px;
}
.checkbox-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.checkbox-label-container {
flex: 0 0 auto;
width: 4em;
margin-right: 5px;
}
.checkbox-description {
flex: 1;
font-size: 15px;
color: var(--subtext0);
margin-left: 10px;
}
@media (min-width: 768px) {
.checkbox-grid {
grid-template-columns: 1fr 1fr;
gap: 0px;
}
}
@media (max-width: 480px) {
.checkbox-item {
flex-direction: column;
align-items: flex-start;
}
.checkbox-description {
margin-left: 24px;
margin-top: 5px;
text-align: left;
}
}
.subscription-info {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-top: 1rem;
font-size: 0.9rem;
background-color: var(--surface0);
padding: 1rem;
border-radius: 8px;
border: 1px solid var(--surface1);
}
.subscription-column {
flex: 1;
min-width: 200px;
color: var(--text);
}
.subscription-column p {
margin: 0.5rem 0;
}
.subscription-column strong {
display: inline-block;
min-width: 100px;
color: var(--lavender);
}
.tab-content {
padding: 1rem;
border-radius: 0 0 3px 3px;
background-color: var(--surface0);
border: 1px solid var(--surface1);
margin-top: 1rem;
}
.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);
}
.button-container button.active {
background-color: var(--mauve);
border-color: var(--mauve);
color: var(--base);
}
@media (max-width: 480px) {
.button-container button {
flex-basis: 100%;
max-width: none;
}
}
</style>

View File

@@ -0,0 +1,151 @@
@import "bootstrap/scss/functions";
@import "bootstrap/scss/variables";
@import "bootstrap/scss/mixins";
// Core variables
$theme-colors: (
"primary": #d43aff,
"secondary": #595b5c,
"success": #00b7ef,
"warning": rgb(225 29 72),
"danger": #eb5424,
"light": #9b9b9b,
"dark": #2f2f2f,
);
// Typography
$font-family-base: "Quicksand", sans-serif;
$font-family-monospace: "Roboto Mono", monospace;
$font-size-base: 1rem;
$line-height-base: 1.8;
$headings-font-weight: normal;
$headings-color: #fff;
// Body
$body-bg: black;
$body-color: #9b9b9b;
// Links
$link-color: #00b7ef;
$link-decoration: none;
$link-hover-decoration: underline;
// Buttons
$btn-padding-y: 1.125rem; // 18px
$btn-padding-x: 1.75rem; // 28px
$btn-font-weight: 500;
$btn-letter-spacing: 1px;
$btn-border-width: 1px;
$btn-transition: all 0.3s ease-in-out;
// Forms
$input-bg: #494848;
$input-color: white;
$input-border-color: #494848;
$input-border-radius: 6px;
$input-padding-y: 0.625rem; // 10px
$input-padding-x: 0.625rem; // 10px
$input-font-family: $font-family-monospace;
$input-font-size: 1rem;
$input-focus-border-color: lighten($input-border-color, 10%);
$input-focus-box-shadow: none;
// Cards
$card-bg: #2f2f2f;
$card-border-radius: 3px;
$card-border-width: 0;
$card-spacer-y: 1.25rem;
$card-spacer-x: 1.25rem;
// Modals
$modal-content-bg: #2f2f2f;
$modal-header-border-color: #595b5c;
$modal-footer-border-color: #595b5c;
// Navbar
$navbar-dark-color: #9b9b9b;
$navbar-dark-hover-color: #fff;
$navbar-padding-y: 1rem;
$navbar-nav-link-padding-x: 1rem;
// Utilities
$border-radius: 3px;
$border-radius-lg: 6px;
$box-shadow: none;
// Custom utility classes
.text-uppercase {
letter-spacing: 1px;
}
// Custom button styles
.btn {
text-transform: uppercase;
&-dark {
@include button-variant(transparent, #595b5c);
&:hover {
border-color: #fff;
background-color: transparent;
}
}
&-primary {
&:hover {
background-color: darken(#d43aff, 5%);
}
}
}
// Custom input styles
.form-control {
&:focus {
background-color: lighten($input-bg, 5%);
}
}
html {
padding: 0 30px;
}
body {
max-width: 1200px;
margin: 5em auto 0 auto;
}
h2 {
margin: 0 0 45px 0;
font-size: 36px;
}
h3 {
margin: 0 0 2rem 0;
font-size: 32px;
}
p {
margin: 0 0 45px;
}
ul {
margin: 0 0 32px;
}
li strong {
color: #fff;
}
pre,
code {
display: inline;
font-size: 16px;
}
// Override Bootstrap's link hover behavior
a:hover {
text-decoration: none;
border-bottom-color: #00b7ef;
}
// Import Bootstrap after variable overrides
@import "bootstrap/scss/bootstrap";

View File

@@ -0,0 +1,513 @@
@font-face {
font-family: "Roboto Mono";
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/robotomono/v22/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_gPq_ROW9.ttf)
format("truetype");
}
@font-face {
font-family: "Roboto Mono";
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/robotomono/v22/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vq_ROW9.ttf)
format("truetype");
}
html {
padding: 0 30px;
background-color: black;
color: #9b9b9b;
font-family: "Quicksand", sans-serif;
font-size: 16px;
font-weight: normal;
}
body {
max-width: 1200px;
margin: 5em auto 0 auto;
}
pre,
code {
display: inline;
font-family: "Roboto Mono", monospace;
font-size: 16px;
}
input {
font-family: "Roboto Mono", monospace;
color: white;
border-style: none;
height: 21px;
font-size: 16px;
}
button {
font-size: 16px;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-weight: normal;
}
h2 {
margin: 0 0 45px 0;
color: #fff;
font-size: 36px;
}
h3 {
margin: 0 0 2rem 0;
color: #fff;
font-size: 32px;
}
p {
margin: 0 0 45px;
line-height: 1.8;
}
ul {
margin: 0 0 32px;
}
a {
transition: border 0.2s ease-in-out;
border-bottom: 1px solid transparent;
text-decoration: none;
color: #00b7ef;
}
a:hover {
border-bottom-color: #00b7ef;
}
li {
line-height: 1.8;
}
li strong {
color: #fff;
}
.image {
width: 100%;
margin: 0 0 32px;
padding: 0;
}
.image img {
width: 100%;
}
.optanon-alert-box-wrapper {
left: 0;
}
.hidden {
display: none !important;
}
.hide-mobile {
display: none;
}
@media (min-width: 680px) {
body {
margin: 8em auto 0 auto;
}
.hide-mobile {
display: initial;
}
}
.header {
position: fixed;
top: 0;
left: 0;
z-index: 1;
box-sizing: border-box;
width: 100%;
padding: 3em 0 0;
background: black;
}
.header.top-banner-open {
margin-top: 5px;
transition: all 0.2s linear;
}
.header .header-container {
width: 100%;
max-width: calc(1200px + 10em);
height: 5em;
display: flex;
flex-direction: column;
align-items: center;
margin: 0 auto;
}
.header .header-container .header-left {
display: flex;
flex-grow: 1;
}
.header .header-container .header-left .header-crafted-by-container {
font-size: 18px;
font-weight: 300;
}
.header .header-container .header-left .header-crafted-by-container a {
display: flex;
color: #9b9b9b;
border: none;
}
.header .header-container .header-left .header-crafted-by-container a img {
height: 28px;
}
.header .header-container .header-left .header-crafted-by-container a span {
display: inline-block;
margin: 2px 1ch 0 0;
}
.header .header-container .header-left .header-crafted-by-container .auth0 {
margin-left: 1ch;
color: #fff;
font-weight: bold;
}
.header .header-container .header-right {
display: flex;
flex-grow: 1;
justify-content: space-between;
letter-spacing: 1px;
font-weight: 500;
}
.header .header-container .header-right .header-nav-item {
text-transform: uppercase;
margin-left: 10px;
}
.header .header-container .header-right .header-nav-item button {
all: unset;
cursor: pointer;
}
.header .header-container .header-right .header-nav-item.active a,
.header .header-container .header-right .header-nav-item.active button {
color: #fff;
}
.header .header-container .header-right a img {
margin-top: -0.4rem;
height: 28px;
}
.header .header-container .header-right .header-nav-item a,
.header .header-container .header-right .header-nav-item button {
transition: color 0.3s ease-in-out;
display: block;
padding: 20px 0;
border: none;
color: #9b9b9b;
}
.header .header-container .header-right .header-nav-item:hover a,
.header .header-container .header-right .header-nav-item:hover button {
color: #fdfff5;
}
@media (min-width: 680px) {
.header {
padding: 3em 5rem 0;
}
.header.top-banner-open {
margin-top: 48px;
}
.header .header-container {
flex-direction: row;
}
.header .header-container .header-right {
justify-content: flex-end;
}
.header .header-container .header-right .header-nav-item {
margin-left: 26px;
}
}
.button-dark {
transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out;
color: white;
text-transform: uppercase;
font-weight: 500;
padding: 18px 28px;
letter-spacing: 1px;
cursor: pointer;
background-color: transparent;
border: 1px solid #595b5c;
margin: 2px;
}
.button-dark:hover {
border-color: #fff;
}
.button-colorful {
transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out;
color: white;
text-transform: uppercase;
font-weight: 500;
padding: 18px 28px;
letter-spacing: 1px;
cursor: pointer;
background-color: #d43aff;
border: 1px solid #d43aff;
}
.button-colorful:hover {
background-color: #c907ff;
border-color: #c907ff;
}
.button-orange {
transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out;
color: white;
text-transform: uppercase;
font-weight: 500;
padding: 18px 28px;
letter-spacing: 1px;
cursor: pointer;
background-color: #eb5424;
border: 1px solid #eb5424;
}
.button-orange:hover {
background-color: #ca3f12;
border-color: #ca3f12;
}
.button-colorful:disabled {
transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out;
color: white;
text-transform: uppercase;
font-weight: 500;
padding: 18px 28px;
letter-spacing: 1px;
cursor: pointer;
background-color: #9a9a9a;
border: 1px solid #9a9a9a;
}
.hero-container {
max-width: 795px;
display: flex;
flex-direction: column;
align-items: center;
margin: 0 auto 70px auto;
}
.hero-container .hero-logo {
margin-top: 88px;
margin-bottom: 32px;
}
.hero-container .hero-subtitle {
font-size: 20px;
text-align: center;
line-height: 32px;
margin: 0 0 45px 0;
}
.hero-container .hero-buttons-container {
display: flex;
}
.hero-container .hero-buttons-container button {
margin: 0 8px;
}
@media (min-width: 680px) {
.hero-container {
margin: 0 auto 140px auto;
}
}
.container {
transition: opacity 0.2s ease-in-out;
color: white;
letter-spacing: 0;
opacity: 1;
}
.container .content {
width: 100%;
flex-grow: 1;
}
.container .content .step-title {
color: #fff;
font-size: 20px;
font-weight: 500;
line-height: 86px;
opacity: 1;
}
.container .content .step-subtitle {
position: relative;
top: -5px;
font-size: 16px;
font-weight: 300;
}
@media (max-width: 680px) {
.container .content {
margin-top: 120px;
}
}
@media (min-width: 680px) {
.container {
position: relative;
left: 100px;
display: flex;
width: calc(100% - 100px);
padding: 0;
}
.container .content {
max-width: 795px;
}
.container .content .step-title {
font-size: 36px;
}
}
.input-box {
display: flex;
align-items: center;
justify-content: space-between;
margin: 10px 0;
padding: 10px;
width: 100%;
height: auto;
box-sizing: border-box;
background-color: #2f2f2f;
border-radius: 3px;
font-family: "Roboto Mono", monospace;
font-size: 13px;
}
.input-box .label {
margin: 0 1ch 0 0;
font-size: 16px;
}
.input-box .input {
background-color: #494848;
border-radius: 6px;
outline: none;
border: 3px solid #494848;
width: 100%;
max-width: 444px;
font-size: 13px;
}
@media (min-width: 680px) {
.input-box {
padding: 0 30px;
}
}
.btn-container {
display: flex;
justify-content: space-between;
align-items: first baseline;
}
@media (max-width: 680px) {
.btn-container {
align-items: flex-start;
}
.btn-container p {
margin-left: 1rem;
}
}
.warning {
margin: 20px 0;
padding: 1rem;
width: 100%;
box-sizing: border-box;
background-color: rgb(255 228 230);
border: 1px solid rgb(225 29 72);
border-radius: 6px;
color: rgb(225 29 72);
font-size: 16px;
}
.warning a {
color: rgb(225 29 72);
text-decoration: underline;
}
.warning.hidden {
display: none;
}
.error {
margin-top: 10rem;
padding: 30px 40px;
background: #2f3132;
color: #fff;
}
.error p {
margin: 0 0 1rem;
}
.error p.intro {
font-size: 1.3rem;
}
.error .button-colorful {
display: inline-block;
}
@media (min-width: 680px) {
.error {
padding: 65px 80px;
}
}
.footer-branding-container {
color: white;
font-weight: 300;
margin-bottom: 73px;
}
.footer-branding-container .footer-branding {
display: flex;
width: 400px;
}
.footer-branding-container .footer-branding {
flex-direction: column;
text-align: center;
margin: 30px 0 0;
}
.footer-branding-container .footer-branding .footer-crafted-by-container {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
color: white;
}
.footer-branding-container .footer-branding .footer-crafted-by-container span {
display: inline-block;
margin: 3px 1ch 0 0;
}
.footer-branding-container
.footer-branding
.footer-crafted-by-container
.footer-branded-crafted-img {
height: 28px;
}
.footer-branding-container .footer-branding .footer-copyright {
color: #696969;
letter-spacing: 0.5px;
}
.footer-container {
width: 100%;
color: white;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
}
@media (min-width: 680px) {
.footer-container {
padding: 0;
}
}
.simple-loader {
--b: 20px; /* border thickness */
--n: 15; /* number of dashes*/
--g: 7deg; /* gap between dashes*/
--c: #d43aff; /* the color */
width: 40px; /* size */
aspect-ratio: 1;
border-radius: 50%;
padding: 1px; /* get rid of bad outlines */
background: conic-gradient(#0000, var(--c)) content-box;
--_m: /* we use +/-1deg between colors to avoid jagged edges */ repeating-conic-gradient(
#0000 0deg,
#000 1deg calc(360deg / var(--n) - var(--g) - 1deg),
#0000 calc(360deg / var(--n) - var(--g)) calc(360deg / var(--n))
),
radial-gradient(
farthest-side,
#0000 calc(98% - var(--b)),
#000 calc(100% - var(--b))
);
-webkit-mask: var(--_m);
mask: var(--_m);
-webkit-mask-composite: destination-in;
mask-composite: intersect;
animation: load 1s infinite steps(var(--n));
}
@keyframes load {
to {
transform: rotate(1turn);
}
}

553
frontend/src/lib/css/styles.min.css vendored Normal file
View File

@@ -0,0 +1,553 @@
:root {
--white: #ffffff;
--black: #000000;
--rosewater: #f5e0dc;
--flamingo: #f2cdcd;
--pink: #f5c2e7;
--mauve: #cba6f7;
--red: #f38ba8;
--maroon: #eba0ac;
--peach: #fab387;
--yellow: #f9e2af;
--light-green: #b5e8b0;
--green: #3a8f46;
--teal: #94e2d5;
--sky: #89dceb;
--sapphire: #74c7ec;
--blue: #89b4fa;
--lavender: #b4befe;
--text: #cdd6f4;
--subtext1: #bac2de;
--subtext0: #a6adc8;
--overlay2: #9399b2;
--overlay1: #7f849c;
--overlay0: #6c7086;
--surface2: #585b70;
--surface1: #45475a;
--surface0: #313244;
--base: #1e1e2e;
--mantle: #181825;
--crust: #11111b;
--modal-backdrop: rgba(49, 50, 68, 0.45); /* For Mocha theme */
/* Bright theme (Latte) colors */
--bright-white: #000000;
--bright-black: #ffffff;
--bright-rosewater: #dc8a78;
--bright-flamingo: #dd7878;
--bright-pink: #ea76cb;
--bright-mauve: #8839ef;
--bright-red: #d20f39;
--bright-maroon: #e64553;
--bright-peach: #fe640b;
--bright-yellow: #df8e1d;
--bright-light-green: #52b05d;
--bright-green: #1b9200;
--bright-teal: #179299;
--bright-sky: #04a5e5;
--bright-sapphire: #209fb5;
--bright-blue: #1e66f5;
--bright-lavender: #7287fd;
--bright-text: #4c4f69;
--bright-subtext1: #5c5f77;
--bright-subtext0: #6c6f85;
--bright-overlay2: #7c7f93;
--bright-overlay1: #8c8fa1;
--bright-overlay0: #9ca0b0;
--bright-surface2: #acb0be;
--bright-surface1: #bcc0cc;
--bright-surface0: #ccd0da;
--bright-base: #eff1f5;
--bright-mantle: #e6e9ef;
--bright-crust: #dce0e8;
--bright-modal-backdrop: rgba(220, 224, 232, 0.45);
}
[data-theme='bright'] {
--white: var(--bright-white);
--black: var(--bright-black);
--rosewater: var(--bright-rosewater);
--flamingo: var(--bright-flamingo);
--pink: var(--bright-pink);
--mauve: var(--bright-mauve);
--red: var(--bright-red);
--maroon: var(--bright-maroon);
--peach: var(--bright-peach);
--yellow: var(--bright-yellow);
--light-green: var(--bright-light-green);
--green: var(--bright-green);
--teal: var(--bright-teal);
--sky: var(--bright-sky);
--sapphire: var(--bright-sapphire);
--blue: var(--bright-blue);
--lavender: var(--bright-lavender);
--text: var(--bright-text);
--subtext1: var(--bright-subtext1);
--subtext0: var(--bright-subtext0);
--overlay2: var(--bright-overlay2);
--overlay1: var(--bright-overlay1);
--overlay0: var(--bright-overlay0);
--surface2: var(--bright-surface2);
--surface1: var(--bright-surface1);
--surface0: var(--bright-surface0);
--base: var(--bright-base);
--mantle: var(--bright-mantle);
--crust: var(--bright-crust);
--modal-backdrop: var(--bright-modal-backdrop);
}
@font-face {
font-family: 'Roboto Mono';
font-style: normal;
font-weight: 300;
src: url(https://fonts.gstatic.com/s/robotomono/v22/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_gPq_ROW9.ttf)
format('truetype');
}
@font-face {
font-family: 'Roboto Mono';
font-style: normal;
font-weight: 400;
src: url(https://fonts.gstatic.com/s/robotomono/v22/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vq_ROW9.ttf)
format('truetype');
}
html {
padding: 0 30px;
background-color: var(--base);
color: var(--text);
font-family: 'Quicksand', sans-serif;
font-size: 16px;
font-weight: normal;
}
body {
max-width: 1200px;
margin: 5em auto 0 auto;
}
pre,
code {
display: inline;
font-family: 'Roboto Mono', monospace;
font-size: 16px;
}
input {
font-family: 'Roboto Mono', monospace;
color: var(--text);
border-style: none;
height: 21px;
font-size: 16px;
}
button {
font-size: 16px;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0;
font-weight: normal;
}
h2 {
margin: 0 0 45px 0;
color: var(--lavender);
font-size: 36px;
}
h3 {
margin: 0 0 2rem 0;
color: var(--lavender);
font-size: 32px;
}
p {
margin: 0 0 45px;
line-height: 1.8;
}
ul {
margin: 0 0 32px;
}
a {
transition: border 0.2s ease-in-out;
border-bottom: 1px solid transparent;
text-decoration: none;
color: var(--blue);
}
a:hover {
border-bottom-color: var(--blue);
}
li {
line-height: 1.8;
}
li strong {
color: var(--text);
}
.image {
width: 100%;
margin: 0 0 32px;
padding: 0;
}
.image img {
width: 100%;
}
.optanon-alert-box-wrapper {
left: 0;
}
.hidden {
display: none !important;
}
.hide-mobile {
display: none;
}
@media (min-width: 680px) {
body {
margin: 8em auto 0 auto;
}
.hide-mobile {
display: initial;
}
}
.button-dark {
transition:
border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
color: var(--white);
text-transform: uppercase;
font-weight: 500;
padding: 18px 28px;
letter-spacing: 1px;
cursor: pointer;
background-color: transparent;
border: 1px solid var(--surface1);
margin: 2px;
}
.button-dark:hover {
border-color: var(--text);
}
.button-colorful {
transition:
border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
color: var(--white);
text-transform: uppercase;
font-weight: 500;
padding: 18px 28px;
letter-spacing: 1px;
cursor: pointer;
background-color: var(--mauve);
border: 1px solid var(--mauve);
}
.button-colorful:hover {
background-color: var(--bright-mauve);
border-color: var(--bright-mauve);
}
.button-orange {
transition:
border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
color: var(--white);
text-transform: uppercase;
font-weight: 500;
padding: 18px 28px;
letter-spacing: 1px;
cursor: pointer;
background-color: var(--peach);
border: 1px solid var(--peach);
}
.button-orange:hover {
background-color: var(--bright-peach);
border-color: var(--bright-peach);
}
.button-colorful:disabled {
transition:
border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
color: var(--white);
text-transform: uppercase;
font-weight: 500;
padding: 18px 28px;
letter-spacing: 1px;
cursor: pointer;
background-color: var(--overlay0);
border: 1px solid var(--overlay0);
}
.hero-container {
max-width: 795px;
display: flex;
flex-direction: column;
align-items: center;
margin: 0 auto 70px auto;
}
.hero-container .hero-logo {
margin-top: 88px;
margin-bottom: 32px;
}
.hero-container .hero-subtitle {
font-size: 20px;
text-align: center;
line-height: 32px;
margin: 0 0 45px 0;
}
.hero-container .hero-buttons-container {
display: flex;
}
.hero-container .hero-buttons-container button {
margin: 0 8px;
}
@media (min-width: 680px) {
.hero-container {
margin: 0 auto 140px auto;
}
}
.container {
transition: opacity 0.2s ease-in-out;
color: var(--white);
letter-spacing: 0;
opacity: 1;
}
.container .content {
width: 100%;
flex-grow: 1;
}
.container .content .step-title {
color: var(--white);
font-size: 20px;
font-weight: 500;
line-height: 86px;
opacity: 1;
}
.container .content .step-subtitle {
position: relative;
top: -5px;
font-size: 16px;
font-weight: 300;
}
@media (max-width: 680px) {
.container .content {
margin-top: 120px;
}
}
@media (min-width: 680px) {
.container {
position: relative;
left: 100px;
display: flex;
width: calc(100% - 100px);
padding: 0;
}
.container .content {
max-width: 795px;
}
.container .content .step-title {
font-size: 36px;
}
}
.input-box {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: auto;
box-sizing: border-box;
background-color: var(--surface0);
border-radius: 3px;
font-family: 'Roboto Mono', monospace;
font-size: 13px;
}
.input-box .label {
margin: 0 1ch 0 0;
font-size: 16px;
}
.input-box .input {
background-color: var(--surface1);
border: 3px solid var(--surface1);
border-radius: 6px;
outline: none;
width: 100%;
max-width: 444px;
font-size: 13px;
}
@media (min-width: 680px) {
.input-box {
padding: 10px;
}
}
.btn-container {
display: flex;
justify-content: space-between;
align-items: first baseline;
}
@media (max-width: 680px) {
.btn-container {
align-items: flex-start;
}
.btn-container p {
margin-left: 1rem;
}
}
.button-group {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.btn {
font-family: 'Roboto Mono', monospace;
letter-spacing: 1px;
padding: 18px 28px;
border: 1px solid;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.5rem;
transition:
border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
border-radius: 5px;
}
.btn.primary {
background-color: var(--blue);
border-color: var(--blue);
color: var(--white);
}
.btn.primary:hover {
background-color: var(--sapphire);
border-color: var(--sapphire);
}
.btn.danger {
background-color: var(--red);
border-color: var(--red);
color: var(--white);
}
.btn.danger:hover {
background-color: var(--maroon);
border-color: var(--maroon);
}
.warning {
margin: 20px 0;
padding: 1rem;
width: 100%;
box-sizing: border-box;
background-color: var(--surface0);
border: 1px solid var(--red);
color: var(--red);
border-radius: 6px;
font-size: 16px;
}
.warning a {
color: var(--red);
text-decoration: underline;
}
.warning.hidden {
display: none;
}
.error {
margin-top: 10rem;
padding: 30px 40px;
background: var(--surface0);
color: var(--text);
}
.error p {
margin: 0 0 1rem;
}
.error p.intro {
font-size: 1.3rem;
}
.error .button-colorful {
display: inline-block;
}
@media (min-width: 680px) {
.error {
padding: 65px 80px;
}
}
.footer-branding-container {
color: var(--white);
font-weight: 300;
margin-bottom: 73px;
}
.footer-branding-container .footer-branding {
display: flex;
width: 400px;
}
.footer-branding-container .footer-branding {
flex-direction: column;
text-align: center;
margin: 30px 0 0;
}
.footer-branding-container .footer-branding .footer-crafted-by-container {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16px;
color: var(--white);
}
.footer-branding-container .footer-branding .footer-crafted-by-container span {
display: inline-block;
margin: 3px 1ch 0 0;
}
.footer-branding-container
.footer-branding
.footer-crafted-by-container
.footer-branded-crafted-img {
height: 28px;
}
.footer-branding-container .footer-branding .footer-copyright {
color: var(--overlay0);
letter-spacing: 0.5px;
}
.footer-container {
width: 100%;
color: var(--white);
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
}
@media (min-width: 680px) {
.footer-container {
padding: 0;
}
}
.simple-loader {
--b: 20px; /* border thickness */
--n: 15; /* number of dashes*/
--g: 7deg; /* gap between dashes*/
--c: var(--mauve); /* Changed loader color to match theme */
width: 40px; /* size */
aspect-ratio: 1;
border-radius: 50%;
padding: 1px; /* get rid of bad outlines */
background: conic-gradient(#0000, var(--c)) content-box;
--_m: /* we use +/-1deg between colors to avoid jagged edges */ repeating-conic-gradient(
#0000 0deg,
#000 1deg calc(360deg / var(--n) - var(--g) - 1deg),
#0000 calc(360deg / var(--n) - var(--g)) calc(360deg / var(--n))
),
radial-gradient(farthest-side, #0000 calc(98% - var(--b)), var(--black) calc(100% - var(--b)));
-webkit-mask: var(--_m);
mask: var(--_m);
-webkit-mask-composite: destination-in;
mask-composite: intersect;
animation: load 1s infinite steps(var(--n));
}
@keyframes load {
to {
transform: rotate(1turn);
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

View File

@@ -0,0 +1,264 @@
export default {
userStatus: {
1: 'Nicht verifiziert',
2: 'Deaktiviert',
3: 'Verifiziert',
4: 'Systemzugang',
5: 'Passiv'
},
userRole: {
'-1': 'Unfallgegner',
0: 'Sponsor',
1: 'Mitglied',
2: 'Betrachter',
4: 'Bearbeiter',
8: 'Administrator'
},
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...',
email: 'Emailadresse eingeben...',
company: 'Firmennamen eingeben...',
first_name: 'Vornamen eingeben...',
last_name: 'Nachnamen eingeben...',
phone: 'Telefonnummer eingeben...',
address: 'Straße und Hausnummer eingeben...',
zip_code: 'Postleitzahl eingeben...',
city: 'Wohnort eingeben...',
bank_name: 'Namen der Bank eingeben...',
parent_member_id: 'Mitgliedsnr des Hauptmitglieds eingeben...',
bank_account_holder: 'Namen eingeben...',
iban: 'IBAN eingeben..',
bic: 'BIC eingeben(Bei nicht deutschen Konten)...',
mandate_reference: 'SEPA Mandatsreferenz eingeben..',
notes: 'Deine Notizen zu {name}...',
licence_number: 'Auf dem Führerschein unter Feld 5',
issued_date: 'Ausgabedatum unter Feld 4a',
expiration_date: 'Ablaufdatum unter Feld 4b',
issuing_country: 'Ausstellendes Land',
subscription_name: 'Name des Tarifmodells',
subscription_details: 'Beschreibe das Tarifmodell...',
subscription_conditions: 'Beschreibe die Bedingungen zur Nutzung...',
search: 'Suchen...'
},
validation: {
required: 'Eingabe benötigt',
password: 'Password zu kurz, mindestens 8 Zeichen',
password_match: 'Passwörter stimmen nicht überein!',
phone: 'Ungültiges Format(+491738762387 oder 0173850698)',
zip_code: 'Ungültige Postleitzahl(Nur deutsche Wohnorte sind zulässig)',
bic: 'Ungültige BIC',
iban: 'Ungültige IBAN',
date: 'Bitte geben Sie ein Datum ein',
email: 'Ungültige Emailadresse',
licence: 'Nummer zu kurz(11 Zeichen)'
},
server: {
general: 'Allgemein',
error: {
invalid_json: 'JSON Daten sind ungültig',
no_auth_token: 'Nicht authorisiert, fehlender oder ungültiger Auth-Token',
jwt_parsing_error: 'Nicht authorisiert, Auth-Token konnte nicht gelesen werden',
unauthorized: 'Sie sind nicht befugt diese Handlung durchzuführen',
internal_server_error:
'Verdammt, Fehler auf unserer Seite, probieren Sie es nochmal, danach rufen Sie jemanden vom Verein an.',
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: {
invalid: 'ungültig',
invalid_user_id: 'Nutzer ID ungültig',
invalid_subscription: 'Model nicht gefunden',
user_not_found: '{field} konnte nicht gefunden werden',
invalid_user_data: 'Nutzerdaten ungültig',
user_not_found_or_wrong_password: 'Existiert nicht oder falsches Passwort',
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',
safe_content: 'I see what you did there! Do not cross this line!',
iban: 'Ungültig. Format: DE07123412341234123412',
bic: 'Ungültig. Format: BELADEBEXXX',
email: 'Format ungültig',
number: 'Ist keine Nummer',
euDriversLicence: 'Ist kein europäischer Führerschein',
lte: 'Ist zu groß/neu',
gt: 'Ist zu klein/alt',
required: 'Feld wird benötigt',
image: 'Dies ist kein Bild',
alphanum: 'beinhaltet ungültige Zeichen',
user_disabled: 'Benutzer ist deaktiviert',
duplicate: 'Schon vorhanden..',
alphaunicode: 'darf nur aus Buchstaben bestehen',
too_soon: 'zu früh'
}
},
licenceCategory: {
AM: 'Mopeds und leichte vierrädrige Kraftfahrzeuge (50ccm, max 45km/h)',
A1: 'Leichte Motorräder (125ccm)',
A2: 'Motorräder mit mittlerer Leistung (max 35kW)',
A: 'Motorräder',
B: 'Kraftfahrzeuge ≤ 3500 kg, ≤ 8 Sitzplätze',
C1: 'Mittelschwere Fahrzeuge -7500 kg',
C: 'Schwere Nutzfahrzeuge > 3500 kg',
D1: 'Kleinbusse 9-16 Sitzplätze',
D: 'Busse > 8 Sitzplätze',
BE: 'Fahrzeugklasse B mit Anhänger',
C1E: 'Fahrzeugklasse C1 mit Anhänger',
CE: 'Fahrzeugklasse C mit Anhänger',
D1E: 'Fahrzeugklasse D1 mit Anhänger',
DE: 'Fahrzeugklasse D mit Anhänger',
L: 'Land-, Forstwirtschaftsfahrzeuge, Stapler max 40km/h',
T: 'Land-, Forstwirtschaftsfahrzeuge, Stapler max 60km/h'
},
users: 'Mitglieder',
user: {
login: 'Nutzer Anmeldung',
edit: 'Nutzer bearbeiten',
create: 'Nutzer erstellen',
user: 'Nutzer',
member: 'Mitglied',
management: 'Mitgliederverwaltung',
id: 'Mitgliedsnr',
first_name: 'Vorname',
last_name: 'Nachname',
phone: 'Telefonnummer',
dateofbirth: 'Geburtstag',
email: 'Email',
membership: 'Mitgliedschaft',
bank_account: 'Kontodaten',
status: 'Status',
role: 'Nutzerrolle',
supporter: 'Sponsor',
opponent: 'Unfallgegner'
},
subscriptions: {
name: 'Modellname',
edit: 'Modell bearbeiten',
create: 'Modell erstellen',
subscription: 'Tarifmodell',
subscriptions: 'Tarifmodelle',
conditions: 'Bedingungen',
monthly_fee: 'Monatliche Gebühr',
hourly_rate: 'Stundensatz',
included_hours_per_year: 'Inkludierte Stunden pro Jahr',
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: {
user_data: 'Lade Nutzerdaten',
subscription_data: 'Lade Modelldaten',
insurance_data: 'Lade Versicherungsdaten',
car_data: 'Lade Fahrzeugdaten',
please_wait: 'Bitte warten...',
updating: 'Aktualisiere...'
},
dialog: {
user_deletion: 'Soll der Nutzer {firstname} {lastname} 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',
confirm: 'Bestätigen',
actions: 'Aktionen',
create: 'Hinzufügen',
edit: 'Bearbeiten',
delete: 'Löschen',
not_set: 'Nicht gesetzt',
noone: 'Niemand',
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',
licence_categories: 'Führerscheinklassen',
subscription: 'Mitgliedschatfsmodell',
licence: 'Führerschein',
licence_number: 'Führerscheinnummer',
insurance: 'Versicherung',
insurance_reference: 'Versicherungsnummer',
issued_date: 'Ausgabedatum',
month: 'Monat',
expiration_date: 'Ablaufdatum',
country: 'Land',
details: 'Details',
unknown: 'Unbekannt',
notes: 'Notizen',
address: 'Straße & Hausnummer',
city: 'Wohnort',
zip_code: 'PLZ',
forgot_password: 'Passwort vergessen?',
password: 'Passwort',
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',
login: 'Anmeldung',
profile: 'Profil',
cars: 'Fahrzeuge',
status: 'Status',
start: 'Beginn',
end: 'Ende',
parent_member_id: 'Hauptmitgliedsnr.',
bank_account_holder: 'Kontoinhaber',
bank_name: 'Bank',
iban: 'IBAN',
bic: 'BIC',
mandate_reference: 'SEPA Mandat',
payments: 'Zahlungen',
add_new: 'Neu',
email_sent: 'Email wurde gesendet..',
verification: 'Verifikation',
// For payments section
payment: {
id: 'Zahlungs-Nr',
amount: 'Betrag',
date: 'Datum',
status: 'Status'
},
// For subscription statuses
subscriptionStatus: {
pending: 'Ausstehend',
completed: 'Abgeschlossen',
failed: 'Fehlgeschlagen',
cancelled: 'Storniert'
}
};

View File

@@ -0,0 +1,210 @@
export default {
userStatus: {
1: 'Not Verified',
2: 'Deactivated',
3: 'Verified',
4: 'System Access',
5: 'Passive'
},
userRole: {
0: 'Sponsor',
1: 'Member',
2: 'Viewer',
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

@@ -0,0 +1,12 @@
export const BASE_API_URI = import.meta.env.DEV
? import.meta.env.VITE_BASE_API_URI_DEV
: 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

@@ -0,0 +1,237 @@
import { quintOut } from 'svelte/easing';
import { crossfade } from 'svelte/transition';
export const [send, receive] = crossfade({
duration: (d) => Math.sqrt(d * 200),
// eslint-disable-next-line no-unused-vars
fallback(node, params) {
const style = getComputedStyle(node);
const transform = style.transform === 'none' ? '' : style.transform;
return {
duration: 600,
easing: quintOut,
css: (t) => `
transform: ${transform} scale(${t});
opacity: ${t}
`
};
}
});
/**
* Validates an email field
* @file lib/utils/helpers/input.validation.ts
* @param {string} email - The email to validate
*/
export const isValidEmail = (email) => {
const EMAIL_REGEX =
/[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/;
return EMAIL_REGEX.test(email.trim());
};
/**
* Validates a strong password field
* @file lib/utils/helpers/input.validation.ts
* @param {string} password - The password to validate
*/
export const isValidPasswordStrong = (password) => {
const strongRegex = new RegExp('^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})');
return strongRegex.test(password.trim());
};
/**
* Validates a medium password field
* @file lib/utils/helpers/input.validation.ts
* @param {string} password - The password to validate
*/
export const isValidPasswordMedium = (password) => {
const mediumRegex = new RegExp(
'^(((?=.*[a-z])(?=.*[A-Z]))|((?=.*[a-z])(?=.*[0-9]))|((?=.*[A-Z])(?=.*[0-9])))(?=.{6,})'
);
return mediumRegex.test(password.trim());
};
/**
* Test whether or not an object is empty.
* @param {Record<string, string>} obj - The object to test
* @returns `true` or `false`
*/
export function isEmpty(obj) {
for (const _i in obj) {
return false;
}
return true;
}
/**
*
* @param {string} dateString
* @returns string
*/
export function toRFC3339(dateString) {
if (!dateString || dateString == '') dateString = '0001-01-01T00:00:00.000Z';
const date = new Date(dateString);
return date.toISOString();
}
/**
*
* @param {string} dateString
* @returns string
*/
export function fromRFC3339(dateString) {
if (!dateString) dateString = '0001-01-01T00:00:00.000Z';
const date = new Date(dateString);
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
*/
export function userDatesFromRFC3339(user) {
if (user.dateofbirth) {
user.dateofbirth = fromRFC3339(user.dateofbirth);
}
if (user.membership) {
if (user.membership.start_date) {
user.membership.start_date = fromRFC3339(user.membership.start_date);
}
if (user.membership.end_date) {
user.membership.end_date = fromRFC3339(user.membership.end_date);
}
}
if (user.licence?.issued_date) {
user.licence.issued_date = fromRFC3339(user.licence.issued_date);
}
if (user.licence?.expiration_date) {
user.licence.expiration_date = fromRFC3339(user.licence.expiration_date);
}
if (user.bank_account && user.bank_account.mandate_date_signed) {
user.bank_account.mandate_date_signed = fromRFC3339(user.bank_account.mandate_date_signed);
}
}
/**
* Formats dates in the user object to RFC3339 format
* @param {App.Locals['user']} user - The user object to format
*/
export function userDatesToRFC3339(user) {
if (user.dateofbirth) {
user.dateofbirth = toRFC3339(user.dateofbirth);
}
if (user.membership) {
if (user.membership.start_date) {
user.membership.start_date = toRFC3339(user.membership.start_date);
}
if (user.membership.end_date) {
user.membership.end_date = toRFC3339(user.membership.end_date);
}
}
if (user.licence?.issued_date) {
user.licence.issued_date = toRFC3339(user.licence.issued_date);
}
if (user.licence?.expiration_date) {
user.licence.expiration_date = toRFC3339(user.licence.expiration_date);
}
if (user.bank_account && user.bank_account.mandate_date_signed) {
user.bank_account.mandate_date_signed = toRFC3339(user.bank_account.mandate_date_signed);
}
}
/**
*
* @param {string | Array<{field: string, key: string}> | Record<string, {key: string}>} obj - The error object to format
* @returns {Array<{
* field: string,
* key: string,
* id: number
* }> } Array of formatted error objects
*/
export function formatError(obj) {
const errors = [];
if (typeof obj === 'object') {
if (Array.isArray(obj)) {
obj.forEach((error) => {
errors.push({
field: error.field,
key: error.key,
id: Math.random() * 1000
});
});
} else {
Object.keys(obj).forEach((field) => {
errors.push({
field: field,
key: obj[field].key,
id: Math.random() * 1000
});
});
}
} else {
errors.push({
field: 'general',
key: obj,
id: 0
});
}
return errors;
}
/**
*
* @param {string | null} newToken - The new token for the cookie to set
* @param {import('@sveltejs/kit').Cookies } cookies - The event object
*/
export function refreshCookie(newToken, cookies) {
if (newToken) {
const match = newToken.match(/jwt=([^;]+)/);
if (match) {
cookies.set('jwt', match[1], {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // Secure in production
sameSite: 'lax',
maxAge: 5 * 24 * 60 * 60 // 5 days in seconds
});
}
}
}
/**
* 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

@@ -0,0 +1,20 @@
import { register, init, getLocaleFromNavigator, locale } from "svelte-i18n";
function setupI18n() {
register("en", () => import("../locales/en.js"));
register("de", () => import("../locales/de.js"));
init({
fallbackLocale: "de",
initialLocale: getLocaleFromNavigator(),
});
}
setupI18n();
/**
* @param {string} newLocale - The new locale to set
*/
export const changeLocale = (newLocale) => {
locale.set(newLocale);
};

View File

@@ -0,0 +1,264 @@
import { defaultBankAccount, defaultMembership } from './defaults';
import { toRFC3339 } from './helpers';
/**
* Converts FormData to a nested object structure
* @param {FormData} formData - The FormData object to convert
* @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) {
/** @type { Partial<App.Locals['user']> | Partial<App.Types['subscription']> | Partial<App.Types['car']> } */
const object = {};
let confirm_password = '';
console.log('Form data entries:');
for (const [key, value] of formData.entries()) {
console.log('Key:', key, 'Value:', value);
if (key == 'confirm_password') {
confirm_password = String(value);
continue;
}
/** @type {string[]} */
const keys = key.match(/\[([^\]]+)\]/g)?.map((k) => k.slice(1, -1)) || [key];
/** @type {Record<string, any>} */
let current = object;
// console.log('Current object state:', JSON.stringify(current));
for (let i = 0; i < keys.length - 1; i++) {
const currentKey = keys[i];
const nextKey = keys[i + 1];
const isNextKeyArrayIndex = !isNaN(Number(nextKey));
if (!current[currentKey]) {
// If next key is a number, initialize an array, otherwise an object
current[currentKey] = isNextKeyArrayIndex ? [] : {};
}
/**
* Move to the next level of the object
* @type {Record<string, any>}
*/
current = current[currentKey];
}
const lastKey = keys[keys.length - 1];
if (key.endsWith('[]')) {
current[lastKey] = current[lastKey] || [];
try {
/** @type {{id: number, category: string}} */
current[lastKey].push(JSON.parse(value.toString()));
} catch {
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 {
current[lastKey] = value;
}
}
}
return { object: object, confirm_password: confirm_password };
}
/**
* Processes the raw form data into the expected membership data structure
* @param { App.Types['membership'] } membership - The raw form data object
* @returns {App.Types['membership']} Processed membership data
*/
export function processMembershipFormData(membership) {
return {
id: Number(membership.id) || 0,
status: Number(membership.status),
start_date: toRFC3339(String(membership.start_date || '')),
end_date: toRFC3339(String(membership.end_date || '')),
parent_member_id: Number(membership.parent_member_id) || 0,
subscription: processSubscriptionFormData(membership.subscription)
};
}
/**
* Processes the raw form data into the expected licence data structure
* @param { App.Types['licence'] } licence - The raw form data object
* @returns {App.Types['licence']} Processed licence data
*/
export function processLicenceFormData(licence) {
return {
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 || []
};
}
/**
* Processes the raw form data into the expected bank_account data structure
* @param { App.Types['bankAccount'] } bank_account - The raw form data object
* @returns {App.Types['bankAccount']} Processed bank_account data
*/
export function processBankAccountFormData(bank_account) {
{
return {
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.dir(rawData.object.licence);
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 subscription data structure
* @param {Partial<App.Types['subscription']>} subscription - The raw form data object
* @returns {App.Types['subscription']} Processed user data
*/
export function processSubscriptionFormData(subscription) {
/** @type {Partial<App.Types['subscription']>} */
let processedData = {
id: Number(subscription.id) || 0,
name: String(subscription.name) || '',
details: String(subscription.details) || '',
conditions: String(subscription.conditions) || '',
hourly_rate: Number(subscription.hourly_rate) || 0,
monthly_fee: Number(subscription.monthly_fee) || 0,
included_hours_per_month: Number(subscription.included_hours_per_month) || 0,
included_hours_per_year: Number(subscription.included_hours_per_year) || 0
};
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 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

@@ -0,0 +1,7 @@
/** @type {import('./$types').LayoutLoad} */
export async function load({ fetch, url, data }) {
const user = data.user;
const subscriptions = data.subscriptions;
const licence_categories = data.licence_categories;
return { fetch, url: url.pathname, user, subscriptions, licence_categories };
}

View File

@@ -0,0 +1,8 @@
/** @type {import('./$types').LayoutServerLoad} */
export async function load({ locals }) {
return {
user: locals.user,
licence_categories: locals.licence_categories,
subscriptions: locals.subscriptions
};
}

View File

@@ -0,0 +1,31 @@
<script>
import Footer from "$lib/components/Footer.svelte";
import Header from "$lib/components/Header.svelte";
import Transition from "$lib/components/Transition.svelte";
import "$lib/css/styles.min.css";
import { waitLocale } from "svelte-i18n";
import { onMount } from "svelte";
import "$lib/utils/i18n.js";
// import "$lib/css/bootstrap-custom.scss";
/** @type {import('./$types').PageData} */
export let data;
let ready = false;
onMount(async () => {
await waitLocale();
ready = true;
});
</script>
{#if ready}
<Transition key={data.url} duration={600}>
<Header />
<slot />
<Footer />
</Transition>
{/if}

View File

@@ -0,0 +1,13 @@
<!-- <script>
import Developer from "$lib/img/hero-image.png";
</script> -->
<div class="hero-container">
<!-- <div class="hero-logo"><img src={Developer} alt="Alexander Stölting" /></div> -->
<h3 class="hero-subtitle subtitle">Backend vom Carsharing Zeug</h3>
<div class="hero-buttons-container">
<a class="button-dark" href="https://carsharing-hasloh.de/" data-learn-more
>Auf zur Carsharing Webseite</a
>
</div>
</div>

View File

@@ -0,0 +1,146 @@
import { BASE_API_URI } from '$lib/utils/constants';
import { formatError, userDatesFromRFC3339 } from '$lib/utils/helpers';
import { fail, redirect } from '@sveltejs/kit';
import { formDataToObject, processUserFormData } from '$lib/utils/processing';
import { base } from '$app/paths';
/**
* @typedef {Object} UpdateData
* @property {Partial<App.Locals['user']>} user
*/
/** @type {import('./$types').PageServerLoad} */
export async function load({ locals, params }) {
// redirect user if not logged in
if (!locals.user) {
throw redirect(302, `${base}/auth/login?next=${base}/auth/about/${params.id}`);
}
}
/** @type {import('./$types').Actions} */
export const actions = {
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current user
* @returns Error data or redirects user to the home page or the previous page
*/
updateUser: async ({ request, fetch, cookies, locals }) => {
let formData = await request.formData();
const rawFormData = formDataToObject(formData);
/** @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;
// console.log('Is creating: ', isCreating);
const apiURL = `${BASE_API_URI}/auth/users/`;
/** @type {RequestInit} */
const requestUpdateOptions = {
method: 'PUT',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Cookie: `jwt=${cookies.get('jwt')}`
},
body: JSON.stringify(processedData)
};
const res = await fetch(apiURL, requestUpdateOptions);
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.errors);
return fail(400, { errors: errors });
}
const response = await res.json();
locals.user = response;
userDatesFromRFC3339(locals.user);
throw redirect(303, `${base}/auth/about/${response.id}`);
},
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current user
* @returns Error data or redirects user to the home page or the previous page
*/
uploadImage: async ({ request, fetch, cookies }) => {
const formData = await request.formData();
/** @type {RequestInit} */
const requestInitOptions = {
method: 'POST',
headers: {
Cookie: `jwt=${cookies.get('jwt')}`
},
body: formData
};
const res = await fetch(`${BASE_API_URI}/file/upload/`, requestInitOptions);
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.errors);
return fail(400, { errors: errors });
}
const response = await res.json();
return {
success: true,
profile_picture: response['']
};
},
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current user
* @returns Error data or redirects user to the home page or the previous page
*/
deleteImage: async ({ request, fetch, cookies }) => {
const formData = await request.formData();
/** @type {RequestInit} */
const requestInitOptions = {
method: 'DELETE',
headers: {
Cookie: `jwt=${cookies.get('jwt')}`
},
body: formData
};
const res = await fetch(`${BASE_API_URI}/file/delete/`, requestInitOptions);
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.errors);
return fail(400, { errors: errors });
}
return {
success: true,
profile_picture: ''
};
}
};

View File

@@ -0,0 +1,164 @@
<script>
import Modal from '$lib/components/Modal.svelte';
import UserEditForm from '$lib/components/UserEditForm.svelte';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { t } from 'svelte-i18n';
/** @type {import('./$types').ActionData} */
export let form;
$: ({ user, licence_categories, subscriptions } = $page.data);
let showModal = false;
const open = () => (showModal = true);
const close = () => {
showModal = false;
if (form) {
form.errors = undefined;
}
};
onMount(() => {
console.dir(user);
});
</script>
<div class="hero-container">
<div class="user-info">
{#if user.status}
<h3 class="hero-subtitle subtitle info-row">
<span class="label">Status:</span>
<span class="value block-value">
<span
>{$t(`userStatus.${user.status}`, {
default: 'unknown status'
})}</span
>
<span>{$t(`userRole.${user.role_id}`, { default: 'unknown' })}</span>
</span>
</h3>
{/if}
<h3 class="hero-subtitle subtitle info-row">
<span class="label">Name:</span>
<span class="value">{`${user.first_name} ${user.last_name}`}</span>
</h3>
{#if user.email}
<h3 class="hero-subtitle subtitle info-row">
<span class="label">Email:</span>
<span class="value">{user.email}</span>
</h3>
{/if}
{#if user.address}
<h3 class="hero-subtitle subtitle info-row">
<span class="label">Adresse:</span>
<span class="value block-value">
<span>{user.address}</span>
<span>{`${user.zip_code} ${user.city}`}</span>
</span>
</h3>
{/if}
{#if user.phone}
<h3 class="hero-subtitle subtitle info-row">
<span class="label">Telefon:</span>
<span class="value">{user.phone}</span>
</h3>
{/if}
{#if user.dateofbirth}
<h3 class="hero-subtitle subtitle info-row">
<span class="label">Geburtstag:</span>
<span class="value">{user.dateofbirth}</span>
</h3>
{/if}
{#if user.notes}
<h3 class="hero-subtitle subtitle info-row">
<span class="label">{$t('notes')}:</span>
<span class="value">{user.notes}</span>
</h3>
{/if}
</div>
<div class="hero-buttons-container">
<button class="button-dark" on:click={open}>Ändern</button>
</div>
</div>
{#if showModal}
<Modal on:close={close}>
<UserEditForm
{form}
{user}
{subscriptions}
{licence_categories}
on:close={close}
on:cancel={close}
editor={user}
/>
</Modal>
{/if}
<style>
.hero-container .hero-subtitle:not(:last-of-type) {
margin: 0 0 0 0;
}
.hero-container {
display: flex;
flex-direction: column;
align-items: center;
}
.user-info {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.5rem 1rem;
align-items: start;
text-align: left;
margin-top: 1rem;
color: var(--text);
background-color: var(--surface0);
padding: 1.5rem;
border-radius: 8px;
border: 1px solid var(--surface1);
}
.info-row {
display: contents;
}
.label {
font-size: 1.3rem;
font-weight: bold;
text-align: left;
padding-right: 1rem;
color: var(--lavender);
}
.value {
margin: 0;
font-size: 1.2rem;
text-align: left;
color: var(--text);
}
.block-value {
display: flex;
align-items: flex-start;
flex-direction: column;
color: var(--subtext0);
}
.hero-buttons-container {
margin-top: 1rem;
}
.user-info {
font-size: 1rem;
}
.label,
.value {
font-size: 1.1rem;
}
</style>

View File

@@ -0,0 +1,8 @@
/** @type {import('./$types').LayoutLoad} */
export async function load({ data }) {
return {
users: data.users,
user: data.user,
cars: data.cars
};
}

View File

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

View File

@@ -0,0 +1,335 @@
// - Add authentication check to ensure only admins can access this route.
// - Implement a load function to fetch a list of all users.
// - Create actions for updating user information (similar to the about/[id] route).
import { BASE_API_URI, PERMISSIONS } from '$lib/utils/constants';
import { formatError, hasPrivilige, userDatesFromRFC3339 } from '$lib/utils/helpers';
import { fail, redirect } from '@sveltejs/kit';
import {
formDataToObject,
processCarFormData,
processSubscriptionFormData,
processUserFormData
} from '$lib/utils/processing';
import { base } from '$app/paths';
/** @type {import('./$types').PageServerLoad} */
export async function load({ locals }) {
// redirect user if not logged in
if (!locals.user) {
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}`);
}
}
/** @type {import('./$types').Actions} */
export const actions = {
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current user
* @returns Error data or redirects user to the home page or the previous page
*/
updateUser: async ({ request, fetch, cookies, locals }) => {
let formData = await request.formData();
const rawFormData = formDataToObject(formData);
/** @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(user.membership);
const isCreating = !user.id || user.id === 0;
console.log('Is creating: ', isCreating);
const apiURL = `${BASE_API_URI}/auth/users`;
/** @type {RequestInit} */
const requestOptions = {
method: isCreating ? 'POST' : 'PUT',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Cookie: `jwt=${cookies.get('jwt')}`
},
body: JSON.stringify(user)
};
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);
locals.user = response;
userDatesFromRFC3339(locals.user);
throw redirect(303, `${base}/auth/admin/users`);
},
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current user
* @returns Error data or redirects user to the home page or the previous page
*/
updateSubscription: async ({ request, fetch, cookies }) => {
let formData = await request.formData();
const rawFormData = formDataToObject(formData);
const rawSubscription = /** @type {Partial<App.Types['subscription']>} */ (rawFormData.object);
const subscription = processSubscriptionFormData(rawSubscription);
const isCreating = !subscription.id || subscription.id === 0;
console.log('Is creating: ', isCreating);
const apiURL = `${BASE_API_URI}/auth/subscriptions`;
/** @type {RequestInit} */
const requestOptions = {
method: isCreating ? 'POST' : 'PUT',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Cookie: `jwt=${cookies.get('jwt')}`
},
body: JSON.stringify(subscription)
};
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 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`);
},
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current user
* @returns
*/
userDelete: async ({ request, fetch, cookies }) => {
let formData = await request.formData();
const rawFormData = formDataToObject(formData);
/** @type {Partial<App.Locals['user']>} */
const rawUser = /** @type {Partial<App.Locals['user']>} */ (rawFormData.object);
const apiURL = `${BASE_API_URI}/auth/users`;
/** @type {RequestInit} */
const requestOptions = {
method: 'DELETE',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Cookie: `jwt=${cookies.get('jwt')}`
},
body: JSON.stringify({ id: Number(rawUser.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
*/
subscriptionDelete: async ({ request, fetch, cookies }) => {
let formData = await request.formData();
const rawData = formDataToObject(formData);
/** @type {Partial<App.Types['subscription']>} */
const subscription = rawData.object;
const apiURL = `${BASE_API_URI}/auth/subscriptions`;
/** @type {RequestInit} */
const requestOptions = {
method: 'DELETE',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
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)
};
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`);
}
};

View File

@@ -0,0 +1,916 @@
<script>
import Modal from '$lib/components/Modal.svelte';
import UserEditForm from '$lib/components/UserEditForm.svelte';
import SubscriptionEditForm from '$lib/components/SubscriptionEditForm.svelte';
import InputField from '$lib/components/InputField.svelte';
import { t } from 'svelte-i18n';
import { page } from '$app/stores';
import { applyAction, enhance } from '$app/forms';
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} */
export let form;
$: ({
user = [],
users = [],
cars = [],
licence_categories = [],
subscriptions = [],
payments = []
} = $page.data);
let activeSection = 'members';
/** @type{App.Types['car'] | App.Types['subscription'] | App.Locals['user'] | null} */
let selected = null;
let searchTerm = '';
$: 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;
$: 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 body = `Hello everyone,\n\nThis is an important message.`;
const bccEmails = filteredUsers
.map((/** @type{App.Locals['user']}*/ user) => user.email)
.join(',');
const encodedSubject = encodeURIComponent(subject);
const encodedBody = encodeURIComponent(body);
const mailtoLink = `mailto:?bcc=${bccEmails}&subject=${encodedSubject}&body=${encodedBody}`;
window.location.href = mailtoLink; // Open the mail client
}
/**
* returns a set of members depending on the entered search query
* @param {App.Locals['user'][]} userSet Set to filter
* @return {App.Locals['user'][]}*/
const getFilteredUsers = (userSet) => {
if (!searchTerm.trim()) return userSet;
const term = searchTerm.trim().toLowerCase();
return userSet.filter((/** @type{App.Locals['user']}*/ user) => {
const basicMatch = [
user.first_name?.toLowerCase(),
user.last_name?.toLowerCase(),
user.email?.toLowerCase(),
user.address?.toLowerCase(),
user.city?.toLowerCase(),
user.dateofbirth?.toLowerCase(),
user.phone?.toLowerCase(),
user.company?.toLowerCase(),
user.licence?.number?.toLowerCase()
].some((field) => field?.includes(term));
const subscriptionMatch = user.membership?.subscription?.name?.toLowerCase().includes(term);
const licenceCategoryMatch = user.licence?.categories?.some((cat) =>
cat.category.toLowerCase().includes(term)
);
const addressMatch = [
user.address?.toLowerCase(),
user.zip_code?.toLowerCase(),
user.city?.toLowerCase()
].some((field) => field?.includes(term));
return basicMatch || subscriptionMatch || licenceCategoryMatch || addressMatch;
});
};
const close = () => {
selected = null;
if (form) {
form.errors = [];
}
};
/**
* sets the active admin section for display
* @param {string} section The new active section.
*/
const setActiveSection = (section) => {
activeSection = section;
};
</script>
<div class="container">
<div class="layout">
<!-- Sidebar -->
<nav class="sidebar">
<ul class="nav-list">
<li>
<button
class="nav-link {activeSection === 'members' ? 'active' : ''}"
on:click={() => setActiveSection('members')}
>
<i class="fas fa-users"></i>
{$t('users')}
<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>
</li>
<li>
<button
class="nav-link {activeSection === 'subscriptions' ? 'active' : ''}"
on:click={() => setActiveSection('subscriptions')}
>
<i class="fas fa-clipboard-list"></i>
{$t('subscriptions.subscriptions')}
<span class="nav-badge">{subscriptions.length}</span>
</button>
</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>
<button
class="nav-link {activeSection === 'payments' ? 'active' : ''}"
on:click={() => setActiveSection('payments')}
>
<i class="fas fa-credit-card"></i>
{$t('payments')}
</button>
</li>
</ul>
</nav>
<!-- Main Content -->
<main class="main-content">
{#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}
{#if activeSection === 'members'}
<div class="section-header">
<h2>{$t('users')}</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 Users"
on:click={() => handleMailButtonClick(filteredMembers)}
>
<i class="fas fa-envelope"></i>
</button>
</div>
<div>
<button
class="btn primary"
on:click={() => {
selected = defaultUser();
}}
>
<i class="fas fa-plus"></i>
{$t('add_new')}
</button>
</div>
</div>
<div class="accordion">
{#each filteredMembers as user}
<details class="accordion-item">
<summary class="accordion-header">
{user.first_name}
{user.last_name}
</summary>
<div class="accordion-content">
<table class="table">
<tbody>
<tr>
<th>{$t('user.id')}</th>
<td>{user.id}</td>
</tr>
<tr>
<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>
</tr>
<tr>
<th>{$t('user.email')}</th>
<td>{user.email}</td>
</tr>
<tr>
<th>{$t('subscriptions.subscription')}</th>
<td>{user.membership?.subscription?.name}</td>
</tr>
<tr>
<th>{$t('status')}</th>
<td>{$t('userStatus.' + user.status)}</td>
</tr>
</tbody>
</table>
<div class="button-group">
<button
class="btn primary"
on:click={() => {
selected = user;
}}
>
<i class="fas fa-edit"></i>
{$t('edit')}
</button>
<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>
</div>
</div>
</details>
{/each}
</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'}
<div class="section-header">
<h2>{$t('subscriptions.subscriptions')}</h2>
{#if hasPrivilige(user, PERMISSIONS.Super)}
<button
class="btn primary"
on:click={() => {
selected = defaultSubscription();
}}
>
<i class="fas fa-plus"></i>
{$t('add_new')}
</button>
{/if}
</div>
<div class="accordion">
{#each subscriptions as subscription}
<details class="accordion-item">
<summary class="accordion-header">
{subscription.name}
<span class="nav-badge"
>{members.filter(
(/** @type{App.Locals['user']}*/ user) =>
user.membership?.subscription?.name === subscription.name
).length}</span
>
</summary>
<div class="accordion-content">
<table class="table">
<tbody>
<tr>
<th>{$t('subscriptions.monthly_fee')}</th>
<td
>{subscription.monthly_fee !== -1
? subscription.monthly_fee + '€'
: '-'}</td
>
</tr>
<tr>
<th>{$t('subscriptions.hourly_rate')}</th>
<td
>{subscription.hourly_rate !== -1
? subscription.hourly_rate + '€'
: '-'}</td
>
</tr>
<tr>
<th>{$t('subscriptions.included_hours_per_year')}</th>
<td>{subscription.included_hours_per_year || 0}</td>
</tr>
<tr>
<th>{$t('subscriptions.included_hours_per_month')}</th>
<td>{subscription.included_hours_per_month || 0}</td>
</tr>
<tr>
<th>{$t('details')}</th>
<td>{subscription.details || '-'}</td>
</tr>
<tr>
<th>{$t('subscriptions.conditions')}</th>
<td>{subscription.conditions || '-'}</td>
</tr>
</tbody>
</table>
<div class="button-group">
{#if hasPrivilige(user, PERMISSIONS.Super)}
<button
class="btn primary"
on:click={() => {
selected = subscription;
}}
>
<i class="fas fa-edit"></i>
{$t('edit')}
</button>
{/if}
{#if !members.some(/** @param{App.Locals['user']} user */ (user) => user.membership?.subscription?.id === subscription.id)}
<form
method="POST"
action="?/subscriptionDelete"
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.subscription_deletion', {
values: {
name: subscription.name || ''
}
})
)
) {
e.preventDefault(); // Cancel form submission if user declines
}
}}
>
<input type="hidden" name="subscription[id]" value={subscription.id} />
<input type="hidden" name="subscription[name]" value={subscription.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 === '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}
</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>
{/each}
</div>
{:else if activeSection === 'payments'}
<h2>{$t('payments')}</h2>
<div class="accordion">
{#each payments as payment}
<details class="accordion-item">
<summary class="accordion-header">
Payment #{payment.id} - {payment.amount}
</summary>
<div class="accordion-content">
<table class="table">
<tbody>
<tr>
<th>Date</th>
<td>{new Date(payment.date).toLocaleDateString()}</td>
</tr>
<tr>
<th>Status</th>
<td>{payment.status}</td>
</tr>
</tbody>
</table>
</div>
</details>
{/each}
</div>
{/if}
</main>
</div>
</div>
{#if selected && 'email' in selected}
// user
<Modal on:close={close}>
<UserEditForm
{form}
editor={user}
user={selected}
{subscriptions}
{licence_categories}
on:cancel={close}
on:close={close}
/>
</Modal>
{:else if selected && 'monthly_fee' in selected}
//subscription
<Modal on:close={close}>
<SubscriptionEditForm
{form}
{user}
subscription={selected}
on:cancel={close}
on:close={close}
/>
</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}
<style>
.title-container {
margin: 0 1rem;
flex-grow: 1;
max-width: 500px;
}
.section-header {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap; /* Allows wrapping on small screens */
margin-bottom: 1rem;
}
.container {
width: 100%;
height: 100%;
padding: 0 1rem;
color: var(--text);
}
.layout {
display: grid;
grid-template-columns: 250px 1fr;
gap: 2rem;
height: 100%;
width: inherit;
}
.sidebar {
width: 250px;
background: var(--surface0);
border-right: 1px solid var(--surface1);
}
.nav-list {
list-style: none;
padding: 0;
margin: 0;
}
.nav-link {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.75rem 1rem;
border: none;
background: none;
text-align: left;
cursor: pointer;
color: var(--subtext0);
text-transform: uppercase;
font-weight: 500;
letter-spacing: 1px;
transition: color 0.3s ease-in-out;
}
.nav-link:hover {
background: var(--surface1);
color: var(--text);
}
.nav-link.active {
background: var(--surface2);
color: var(--lavender);
border-left: 3px solid var(--mauve);
}
.main-content {
padding: 2rem;
min-width: 75%;
}
.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);
}
.table {
width: 100%;
border-collapse: collapse;
font-family: 'Roboto Mono', monospace;
}
.table th,
.table td {
padding: 0.75rem;
border-bottom: 1px solid #2f2f2f;
text-align: left;
}
.table th {
color: var(--subtext1);
}
.table td {
color: var(--text);
}
@media (max-width: 680px) {
.layout {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
width: 100%;
padding: 1rem 0;
}
.main-content {
margin-left: 0;
margin-top: 120px;
}
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.section-header h2 {
margin: 0;
color: var(--lavender);
}
/* Additional styles for better visual hierarchy */
details[open] .accordion-header {
background: var(--surface2);
color: var(--lavender);
}
.button-group {
margin-top: 1rem;
display: flex;
gap: 0.5rem;
}
.nav-badge {
background: var(--surface2);
color: var(--text);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
margin-left: auto;
}
.nav-link:focus,
.accordion-header:focus {
outline: 2px solid var(--mauve);
outline-offset: -2px;
border-radius: 8px;
}
/* Add subtle transitions */
.accordion-item,
.accordion-header,
.nav-link {
transition: all 0.2s ease-in-out;
}
</style>

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

@@ -0,0 +1,77 @@
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').PageServerLoad} */
export async function load({ locals }) {
// redirect user if logged in
console.log('loading login page');
if (locals.user) {
console.log('user is logged in');
throw redirect(302, `${base}/auth/about/${locals.user.id}`);
}
}
/** @type {import('./$types').Actions} */
export const actions = {
/**
*
* @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
*/
login: async ({ request, fetch, cookies }) => {
console.log('login action called');
const data = await request.formData();
const email = String(data.get('email'));
const password = String(data.get('password'));
const next = String(data.get('next'));
/** @type {RequestInit} */
const requestInitOptions = {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email,
password: password
})
};
console.log('API call url:', `${BASE_API_URI}/users/login`);
const res = await fetch(`${BASE_API_URI}/users/login`, requestInitOptions);
console.log('Login response status:', res.status);
console.log('Login response headers:', Object.fromEntries(res.headers));
if (!res.ok) {
const errorData = await res.json();
const errors = formatError(errorData.errors);
return fail(res.status, { errors });
}
const responseBody = await res.json();
console.log('Login response body:', responseBody);
// Extract the JWT from the response headers
const setCookieHeader = res.headers.get('set-cookie');
if (setCookieHeader) {
const jwtMatch = setCookieHeader.match(/jwt=([^;]+)/);
if (jwtMatch) {
const jwtValue = jwtMatch[1];
// Set the cookie for the client
cookies.set('jwt', jwtValue, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // Secure in production
sameSite: 'lax',
maxAge: 5 * 24 * 60 * 60 // 5 days in seconds
});
}
}
console.log('Redirecting to:', next || `${base}/auth/about/${responseBody.user_id}`);
throw redirect(303, next || `${base}/auth/about/${responseBody.user_id}`);
}
};

View File

@@ -0,0 +1,81 @@
<script>
import { applyAction, enhance } from '$app/forms';
import { base } from '$app/paths';
import { page } from '$app/stores';
import { receive, send } from '$lib/utils/helpers';
import { t } from 'svelte-i18n';
/** @type {import('./$types').ActionData} */
export let form;
/** @type {import('./$types').SubmitFunction} */
const handleLogin = async () => {
return async ({ result }) => {
await applyAction(result);
};
};
let message = '';
if ($page.url.searchParams.get('message')) {
message = $page.url.search.split('=')[1].replaceAll('%20', ' ');
}
</script>
<div class="container">
<form class="content" method="POST" action="?/login" use:enhance={handleLogin}>
<h1 class="step-title">{$t('user.login')}</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.key)}
</h4>
{/each}
{/if}
{#if message}
<h4 class="step-subtitle">{$t(message)}</h4>
{/if}
<input type="hidden" name="next" value={$page.url.searchParams.get('next')} />
<div class="input-box">
<span class="label">{$t('user.email')}:</span>
<input class="input" type="email" name="email" placeholder="{$t('placeholder.email')} " />
</div>
<div class="input-box">
<span class="label">{$t('password')}:</span>
<div class="input-wrapper">
<input
class="input"
type="password"
name="password"
placeholder={$t('placeholder.password')}
/>
<a href={`${base}/auth/password/change`} class="forgot-password">{$t('forgot_password')}?</a
>
</div>
</div>
<div class="btn-container">
<button class="button-dark">{$t('login')} </button>
</div>
</form>
</div>
<style>
.input-wrapper {
display: flex;
flex-direction: column;
align-items: flex-end;
width: 100%;
max-width: 444px;
margin-top: 30px;
}
.forgot-password {
margin-top: 0.5rem;
font-size: 0.9em;
}
</style>

View File

@@ -0,0 +1,55 @@
import { base } from '$app/paths';
import { BASE_API_URI } from '$lib/utils/constants';
import { fail, redirect } from '@sveltejs/kit';
/** @type {import('./$types').PageServerLoad} */
export async function load({ locals }) {
// redirect user if not logged in
if (!locals.user) {
throw redirect(302, `${base}/auth/login?next=${base}/`);
}
}
/** @type {import('./$types').Actions} */
export const actions = {
default: async ({ fetch, cookies }) => {
/** @type {RequestInit} */
const requestInitOptions = {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Cookie: `jwt=${cookies.get('jwt')}`
}
};
const res = await fetch(`${BASE_API_URI}/auth/logout/`, requestInitOptions);
if (!res.ok) {
const response = await res.json();
const errors = [];
errors.push({ error: response.error, id: 0 });
return fail(400, { errors: errors });
}
// eat the cookie
cookies.delete('jwt', { path: '/' });
// The server should clear the cookie, so we don't need to handle it here
// Just check if the cookie is cleared in the response
const setCookieHeader = res.headers.get('set-cookie');
if (!setCookieHeader || !setCookieHeader.includes('jwt=;')) {
console.error('JWT cookie not cleared in response');
return fail(500, {
errors: [
{
error: 'Server error: Failed to clear authentication token',
id: Date.now()
}
]
});
}
// redirect the user
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>

18
frontend/svelte.config.js Normal file
View File

@@ -0,0 +1,18 @@
import adapter from '@sveltejs/adapter-node';
const isProduction = process.env.NODE_ENV === 'production';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// 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.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter(),
paths: {
base: isProduction ? '/backend' : ''
}
}
};
export default config;

10
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config';
import { sveltekit } from '@sveltejs/kit/vite';
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
}
});

View File

@@ -18,18 +18,18 @@ func main() {
config.LoadConfig() config.LoadConfig()
err := database.Open(config.DB.Path) 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

@@ -1,6 +1,10 @@
{ {
"WebsiteTitle": "My Carsharing Site", "site": {
"BaseURL": "https://domain.de", "WebsiteTitle": "My Carsharing Site",
"BaseUrl": "https://domain.de",
"FrontendPath": "",
"AllowOrigins": "https://domain.de"
},
"Environment": "dev", "Environment": "dev",
"db": { "db": {
"Path": "data/db.sqlite3" "Path": "data/db.sqlite3"
@@ -9,21 +13,21 @@
"Host": "mail.server.com", "Host": "mail.server.com",
"User": "username", "User": "username",
"Password": "password", "Password": "password",
"Port": 465, "Port": 465
"AdminEmail": "admin@server.com"
}, },
"templates": { "templates": {
"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": ""
}, },
"recipients": { "recipients": {
"ContactForm": "contacts@server.com", "ContactForm": "contacts@server.com",
"UserRegistration": "registration@server.com" "UserRegistration": "registration@server.com",
"AdminEmail": "admin@server.com"
}, },
"security": { "security": {
"RateLimits": { "RateLimits": {

View File

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

View File

@@ -12,16 +12,21 @@ require (
) )
require ( require (
github.com/alexedwards/argon2id v1.0.0
github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/cors v1.7.2
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
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

@@ -1,3 +1,5 @@
github.com/alexedwards/argon2id v1.0.0 h1:wJzDx66hqWX7siL/SRUmgz3F8YMrd/nfX/xHHcQQP0w=
github.com/alexedwards/argon2id v1.0.0/go.mod h1:tYKkqIjzXvZdzPvADMWOEZ+l6+BD6CtBXMj5fnJppiw=
github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg= github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg=
github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
@@ -28,9 +30,13 @@ github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jbub/banking v0.8.0 h1:79kXJj1X2E9dWdWuFNkk2Pw7c6uYPFQS8ev0l+zMFxk= github.com/jbub/banking v0.8.0 h1:79kXJj1X2E9dWdWuFNkk2Pw7c6uYPFQS8ev0l+zMFxk=
github.com/jbub/banking v0.8.0/go.mod h1:ctv/bD2EGRR5PobFrJSXZ/FZXCFtUbmVv6v2qf/b/88= github.com/jbub/banking v0.8.0/go.mod h1:ctv/bD2EGRR5PobFrJSXZ/FZXCFtUbmVv6v2qf/b/88=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -85,21 +91,60 @@ 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=
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=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=

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"
) )
@@ -23,6 +24,12 @@ type DatabaseConfig struct {
Path string `json:"Path" default:"data/db.sqlite3" envconfig:"DB_PATH"` Path string `json:"Path" default:"data/db.sqlite3" envconfig:"DB_PATH"`
} }
type SiteConfig struct {
AllowOrigins string `json:"AllowOrigins" envconfig:"ALLOW_ORIGINS"`
WebsiteTitle string `json:"WebsiteTitle" envconfig:"WEBSITE_TITLE"`
BaseURL string `json:"BaseUrl" envconfig:"BASE_URL"`
FrontendPath string `json:"FrontendPath" envconfig:"FRONTEND_PATH"`
}
type AuthenticationConfig struct { type AuthenticationConfig struct {
JWTSecret string JWTSecret string
CSRFSecret string CSRFSecret string
@@ -30,11 +37,10 @@ type AuthenticationConfig struct {
} }
type SMTPConfig struct { type SMTPConfig struct {
Host string `json:"Host" envconfig:"SMTP_HOST"` Host string `json:"Host" envconfig:"SMTP_HOST"`
User string `json:"User" envconfig:"SMTP_USER"` User string `json:"User" envconfig:"SMTP_USER"`
Password string `json:"Password" envconfig:"SMTP_PASS"` Password string `json:"Password" envconfig:"SMTP_PASS"`
AdminEmail string `json:"AdminEmail" envconfig:"ADMIN_MAIL"` Port int `json:"Port" default:"465" envconfig:"SMTP_PORT"`
Port int `json:"Port" default:"465" envconfig:"SMTP_PORT"`
} }
type TemplateConfig struct { type TemplateConfig struct {
@@ -47,6 +53,7 @@ type TemplateConfig struct {
type RecipientsConfig struct { type RecipientsConfig struct {
ContactForm string `json:"ContactForm" envconfig:"RECIPIENT_CONTACT_FORM"` ContactForm string `json:"ContactForm" envconfig:"RECIPIENT_CONTACT_FORM"`
UserRegistration string `json:"UserRegistration" envconfig:"RECIPIENT_USER_REGISTRATION"` UserRegistration string `json:"UserRegistration" envconfig:"RECIPIENT_USER_REGISTRATION"`
AdminEmail string `json:"AdminEmail" envconfig:"ADMIN_MAIL"`
} }
type SecurityConfig struct { type SecurityConfig struct {
@@ -55,32 +62,43 @@ 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"`
Templates TemplateConfig `json:"templates"` Templates TemplateConfig `json:"templates"`
Recipients RecipientsConfig `json:"recipients"` Recipients RecipientsConfig `json:"recipients"`
ConfigFilePath string `json:"config_file_path" envconfig:"CONFIG_FILE_PATH"` ConfigFilePath string `json:"config_file_path" envconfig:"CONFIG_FILE_PATH"`
WebsiteTitle string `json:"WebsiteTitle" envconfig:"WEBSITE_TITLE"`
BaseURL string `json:"BaseUrl" envconfig:"BASE_URL"`
Env string `json:"Environment" default:"development" envconfig:"ENV"` Env string `json:"Environment" default:"development" envconfig:"ENV"`
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 (
BaseURL string Site SiteConfig
WebsiteTitle string CFGPath string
CFGPath string CFG Config
CFG Config Auth AuthenticationConfig
Auth AuthenticationConfig DB DatabaseConfig
DB DatabaseConfig Templates TemplateConfig
Templates TemplateConfig SMTP SMTPConfig
SMTP SMTPConfig 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,
@@ -92,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)
} }
@@ -115,11 +133,11 @@ func LoadConfig() {
DB = CFG.DB DB = CFG.DB
Templates = CFG.Templates Templates = CFG.Templates
SMTP = CFG.SMTP SMTP = CFG.SMTP
BaseURL = CFG.BaseURL
Recipients = CFG.Recipients Recipients = CFG.Recipients
Security = CFG.Security Security = CFG.Security
Env = CFG.Env Env = CFG.Env
WebsiteTitle = CFG.WebsiteTitle Site = CFG.Site
Company = CFG.Company
logger.Info.Printf("Config loaded: %#v", CFG) logger.Info.Printf("Config loaded: %#v", CFG)
} }
@@ -157,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,71 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
type SQLInjectionTest struct {
name string
email string
password string
expectedStatus int
}
func (sit *SQLInjectionTest) SetupContext() (*gin.Context, *httptest.ResponseRecorder, *gin.Engine) {
loginData := loginInput{
Email: sit.email,
Password: sit.password,
}
jsonData, _ := json.Marshal(loginData)
return GetMockedJSONContext(jsonData, "/login")
}
func (sit *SQLInjectionTest) RunHandler(c *gin.Context, router *gin.Engine) {
router.POST("/login", Uc.LoginHandler)
router.ServeHTTP(c.Writer, c.Request)
}
func (sit *SQLInjectionTest) ValidateResponse(w *httptest.ResponseRecorder) error {
if sit.expectedStatus != w.Code {
responseBody, _ := io.ReadAll(w.Body)
return fmt.Errorf("SQL Injection Attempt: Didn't get the expected response code: got: %v; expected: %v. Context: %#v", w.Code, sit.expectedStatus, string(responseBody))
}
return nil
}
func (sit *SQLInjectionTest) ValidateResult() error {
// Add any additional validation if needed
return nil
}
func testSQLInjectionAttempt(t *testing.T) {
tests := []SQLInjectionTest{
{
name: "SQL Injection Attempt in Email",
email: "' OR '1'='1",
password: "password123",
expectedStatus: http.StatusNotFound,
},
{
name: "SQL Injection Attempt in Password",
email: "user@example.com",
password: "' OR '1'='1",
expectedStatus: http.StatusNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := runSingleTest(&tt); err != nil {
t.Errorf("Test failed: %v", err.Error())
}
})
}
}

View File

@@ -0,0 +1,31 @@
package controllers
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func testXSSAttempt(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/register", Uc.RegisterUser)
xssPayload := "<script>alert('XSS')</script>"
user := getBaseUser()
user.FirstName = xssPayload
user.Email = "user@xss.hack"
jsonData, _ := json.Marshal(RegistrationData{User: user})
req, _ := http.NewRequest("POST", "/register", bytes.NewBuffer(jsonData))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.NotContains(t, w.Body.String(), xssPayload)
}

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

@@ -22,7 +22,7 @@ type RelayContactRequestTest struct {
Assert bool Assert bool
} }
func TestContactController(t *testing.T) { func testContactController(t *testing.T) {
tests := getContactData() tests := getContactData()
for _, tt := range tests { for _, tt := range tests {

View File

@@ -10,17 +10,20 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"testing" "testing"
"time"
"log" "log"
"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"
"GoMembership/internal/services" "GoMembership/internal/services"
"GoMembership/internal/utils" "GoMembership/internal/utils"
"GoMembership/internal/validation"
"GoMembership/pkg/logger" "GoMembership/pkg/logger"
) )
@@ -36,17 +39,21 @@ const (
Port int = 2525 Port int = 2525
) )
type loginInput struct {
Email string `json:"email"`
Password string `json:"password"`
}
var ( 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")
if err := database.Open("test.db"); err != nil {
log.Fatalf("Failed to create DB: %#v", err)
}
cwd, err := os.Getwd() cwd, err := os.Getwd()
if err != nil { if err != nil {
@@ -76,7 +83,14 @@ func TestSuite(t *testing.T) {
if err := os.Setenv("BASE_URL", "http://"+Host+":2525"); err != nil { if err := os.Setenv("BASE_URL", "http://"+Host+":2525"); err != nil {
log.Fatalf("Error setting environment variable: %v", err) log.Fatalf("Error setting environment variable: %v", err)
} }
if err := os.Setenv("DB_PATH", "test.db"); err != nil {
log.Fatalf("Error setting environment variable: %v", err)
}
config.LoadConfig() config.LoadConfig()
db, err := database.Open("test.db", config.Recipients.AdminEmail, true)
if err != nil {
log.Fatalf("Failed to create DB: %#v", err)
}
utils.SMTPStart(Host, Port) utils.SMTPStart(Host, Port)
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{}
@@ -86,46 +100,110 @@ 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 userRepo repositories.UserRepositoryInterface = &repositories.UserRepository{} var licenceRepo repositories.LicenceInterface = &repositories.LicenceRepository{}
userService := &services.UserService{Repo: userRepo} userService := &services.UserService{DB: db, Licences: licenceRepo}
Uc = &UserController{Service: userService, EmailService: emailService, ConsentService: consentService, BankAccountService: bankAccountService, MembershipService: membershipService} licenceService := &services.LicenceService{Repo: licenceRepo}
Mc = &MembershipController{Service: *membershipService}
Uc = &UserController{Service: userService, LicenceService: licenceService, EmailService: emailService, ConsentService: consentService, BankAccountService: bankAccountService, MembershipService: 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 {
log.Fatalf("Failed to init Subscription plans: %#v", err) log.Fatalf("Failed to init Subscription plans: %#v", err)
} }
// Run all tests if err := initLicenceCategories(); err != nil {
// code := m.Run() log.Fatalf("Failed to init Categories: %v", err)
}
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) {
testSQLInjectionAttempt(t)
}) })
t.Run("contactController", func(t *testing.T) { t.Run("contactController", func(t *testing.T) {
TestContactController(t) testContactController(t)
}) })
t.Run("membershipController", func(t *testing.T) { t.Run("membershipController", func(t *testing.T) {
TestMembershipController(t) testMembershipController(t)
}) })
t.Run("XSSAttempt", func(t *testing.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)
} }
if err := deleteTestDB("test.db"); err != nil { // if err := deleteTestDB("test.db"); err != nil {
log.Fatalf("Failed to tear down DB: %#v", err) // log.Fatalf("Failed to tear down DB: %#v", err)
// }
}
func initLicenceCategories() error {
categories := []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"},
} }
for _, category := range categories {
result := database.DB.Create(&category)
if result.Error != nil {
return result.Error
}
}
return nil
} }
func initSubscriptionPlans() error { func initSubscriptionPlans() error {
subscriptions := []models.SubscriptionModel{ subscriptions := []models.Subscription{
{ {
Name: "Basic", Name: "Basic",
Details: "Test Plan", Details: "Test Plan",
@@ -195,6 +273,43 @@ func GetMockedFormContext(formData url.Values, url string) (*gin.Context, *httpt
return c, w, router return c, w, router
} }
func getBaseUser() models.User {
return models.User{
DateOfBirth: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC),
FirstName: "John",
LastName: "Doe",
Email: "john.doe@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: 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

@@ -0,0 +1,27 @@
package controllers
import (
"GoMembership/internal/services"
"GoMembership/internal/utils"
"GoMembership/pkg/errors"
"net/http"
"github.com/gin-gonic/gin"
)
type LicenceController struct {
Service services.LicenceServiceInterface
}
func (lc *LicenceController) GetAllCategories(c *gin.Context) {
categories, err := lc.Service.GetAllCategories()
if err != nil {
utils.RespondWithError(c, err, "Error retrieving licence categories", http.StatusInternalServerError, errors.Responses.Fields.Licences, errors.Responses.Keys.InternalServerError)
return
}
c.JSON(http.StatusOK, gin.H{
"licence_categories": categories,
})
}

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

@@ -0,0 +1,473 @@
package controllers
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"GoMembership/internal/database"
"GoMembership/internal/models"
"GoMembership/pkg/logger"
"github.com/gin-gonic/gin"
)
type RegisterSubscriptionTest struct {
SetupCookie func(r *http.Request)
WantDBData map[string]interface{}
Input string
Name string
WantResponse int
Assert bool
}
type UpdateSubscriptionTest struct {
SetupCookie func(r *http.Request)
WantDBData map[string]interface{}
Input string
Name string
WantResponse int
Assert bool
}
type DeleteSubscriptionTest struct {
SetupCookie func(r *http.Request)
WantDBData map[string]interface{}
Input string
Name string
WantResponse int
Assert bool
}
func testMembershipController(t *testing.T) {
tests := getSubscriptionRegistrationData()
for _, tt := range tests {
logger.Error.Print("==============================================================")
logger.Error.Printf("MembershipController : %v", tt.Name)
logger.Error.Print("==============================================================")
t.Run(tt.Name, func(t *testing.T) {
if err := runSingleTest(&tt); err != nil {
t.Errorf("Test failed: %v", err.Error())
}
})
}
updateTests := getSubscriptionUpdateData()
for _, tt := range updateTests {
logger.Error.Print("==============================================================")
logger.Error.Printf("Update SubscriptionData : %v", tt.Name)
logger.Error.Print("==============================================================")
t.Run(tt.Name, func(t *testing.T) {
if err := runSingleTest(&tt); err != nil {
t.Errorf("Test failed: %v", err.Error())
}
})
}
deleteTests := getSubscriptionDeleteData()
for _, tt := range deleteTests {
logger.Error.Print("==============================================================")
logger.Error.Printf("Delete SubscriptionData : %v", tt.Name)
logger.Error.Print("==============================================================")
t.Run(tt.Name, func(t *testing.T) {
if err := runSingleTest(&tt); err != nil {
t.Errorf("Test failed: %v", err.Error())
}
})
}
}
func (rt *RegisterSubscriptionTest) SetupContext() (*gin.Context, *httptest.ResponseRecorder, *gin.Engine) {
return GetMockedJSONContext([]byte(rt.Input), "api/subscription")
}
func (rt *RegisterSubscriptionTest) RunHandler(c *gin.Context, router *gin.Engine) {
rt.SetupCookie(c.Request)
Mc.RegisterSubscription(c)
}
func (rt *RegisterSubscriptionTest) ValidateResponse(w *httptest.ResponseRecorder) error {
if w.Code != rt.WantResponse {
return fmt.Errorf("Didn't get the expected response code: got: %v; expected: %v", w.Code, rt.WantResponse)
}
return nil
}
func (rt *RegisterSubscriptionTest) ValidateResult() error {
return validateSubscription(rt.Assert, rt.WantDBData)
}
func validateSubscription(assert bool, wantDBData map[string]interface{}) error {
subscriptions, err := Mc.Service.GetSubscriptions(wantDBData)
if err != nil {
return fmt.Errorf("Error in database ops: %#v", err)
}
if assert != (len(*subscriptions) != 0) {
return fmt.Errorf("Subscription entry query didn't met expectation: %v != %#v", assert, *subscriptions)
}
return nil
}
func (ut *UpdateSubscriptionTest) SetupContext() (*gin.Context, *httptest.ResponseRecorder, *gin.Engine) {
return GetMockedJSONContext([]byte(ut.Input), "api/subscription/upsert")
}
func (ut *UpdateSubscriptionTest) RunHandler(c *gin.Context, router *gin.Engine) {
ut.SetupCookie(c.Request)
Mc.UpdateHandler(c)
}
func (ut *UpdateSubscriptionTest) ValidateResponse(w *httptest.ResponseRecorder) error {
if w.Code != ut.WantResponse {
return fmt.Errorf("Didn't get the expected response code: got: %v; expected: %v", w.Code, ut.WantResponse)
}
return nil
}
func (ut *UpdateSubscriptionTest) ValidateResult() error {
return validateSubscription(ut.Assert, ut.WantDBData)
}
func (dt *DeleteSubscriptionTest) SetupContext() (*gin.Context, *httptest.ResponseRecorder, *gin.Engine) {
return GetMockedJSONContext([]byte(dt.Input), "api/subscription/delete")
}
func (dt *DeleteSubscriptionTest) RunHandler(c *gin.Context, router *gin.Engine) {
dt.SetupCookie(c.Request)
Mc.DeleteSubscription(c)
}
func (dt *DeleteSubscriptionTest) ValidateResponse(w *httptest.ResponseRecorder) error {
if w.Code != dt.WantResponse {
return fmt.Errorf("Didn't get the expected response code: got: %v; expected: %v", w.Code, dt.WantResponse)
}
return nil
}
func (dt *DeleteSubscriptionTest) ValidateResult() error {
return validateSubscription(dt.Assert, dt.WantDBData)
}
func getBaseSubscription() models.Subscription {
return models.Subscription{
Name: "Premium",
Details: "A subscription detail",
MonthlyFee: 12.0,
HourlyRate: 14.0,
}
}
func customizeSubscription(customize func(models.Subscription) models.Subscription) models.Subscription {
subscription := getBaseSubscription()
return customize(subscription)
}
func getSubscriptionRegistrationData() []RegisterSubscriptionTest {
return []RegisterSubscriptionTest{
{
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Missing details should fail",
WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": "Just a Subscription"},
Assert: false,
Input: GenerateInputJSON(
customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Details = ""
return subscription
})),
},
{
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Missing model name should fail",
WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": ""},
Assert: false,
Input: GenerateInputJSON(
customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Name = ""
return subscription
})),
},
{
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Negative monthly fee should fail",
WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": "Premium"},
Assert: false,
Input: GenerateInputJSON(customizeSubscription(func(sub models.Subscription) models.Subscription {
sub.MonthlyFee = -10.0
return sub
})),
},
{
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Negative hourly rate should fail",
WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": "Premium"},
Assert: false,
Input: GenerateInputJSON(customizeSubscription(func(sub models.Subscription) models.Subscription {
sub.HourlyRate = -1.0
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",
WantResponse: http.StatusCreated,
WantDBData: map[string]interface{}{"name": "Premium"},
Assert: true,
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: "Duplicate subscription name should fail",
WantResponse: http.StatusConflict,
WantDBData: map[string]interface{}{"name": "Premium"},
Assert: true, // The original subscription should still exist
Input: GenerateInputJSON(getBaseSubscription()),
},
}
}
func getSubscriptionUpdateData() []UpdateSubscriptionTest {
return []UpdateSubscriptionTest{
{
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Modified Monthly Fee, should fail",
WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": "Premium", "monthly_fee": "12"},
Assert: true,
Input: GenerateInputJSON(
customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.MonthlyFee = 123.0
return subscription
})),
},
{
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Missing ID, should fail",
WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": "Premium"},
Assert: true,
Input: GenerateInputJSON(
customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.ID = 0
return subscription
})),
},
{
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Modified Hourly Rate, should fail",
WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": "Premium", "hourly_rate": "14"},
Assert: true,
Input: GenerateInputJSON(
customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.HourlyRate = 3254.0
return subscription
})),
},
{
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "IncludedPerYear changed, should fail",
WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": "Premium", "included_per_year": "0"},
Assert: true,
Input: GenerateInputJSON(
customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.IncludedPerYear = 9873.0
return subscription
})),
},
{
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "IncludedPerMonth changed, should fail",
WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": "Premium", "included_per_month": "1"},
Assert: true,
Input: GenerateInputJSON(
customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.IncludedPerMonth = 23415.0
return subscription
})),
},
{
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Update non-existent subscription should fail",
WantResponse: http.StatusNotFound,
WantDBData: map[string]interface{}{"name": "NonExistentSubscription"},
Assert: false,
Input: GenerateInputJSON(
customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Name = "NonExistentSubscription"
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",
WantResponse: http.StatusAccepted,
WantDBData: map[string]interface{}{"name": "Premium", "details": "Altered Details"},
Assert: true,
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
})),
},
}
}
func getSubscriptionDeleteData() []DeleteSubscriptionTest {
var premiumSub, basicSub models.Subscription
database.DB.Where("name = ?", "Premium").First(&premiumSub)
database.DB.Where("name = ?", "Basic").First(&basicSub)
return []DeleteSubscriptionTest{
{
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Delete non-existent subscription should fail",
WantResponse: http.StatusNotFound,
WantDBData: map[string]interface{}{"name": "NonExistentSubscription"},
Assert: false,
Input: GenerateInputJSON(
customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Name = "NonExistentSubscription"
subscription.ID = basicSub.ID
logger.Error.Printf("subscription to delete: %#v", subscription)
return subscription
})),
},
{
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Delete subscription without name should fail",
WantResponse: http.StatusBadRequest,
WantDBData: map[string]interface{}{"name": ""},
Assert: false,
Input: GenerateInputJSON(
customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Name = ""
subscription.ID = basicSub.ID
return subscription
})),
},
{
SetupCookie: func(req *http.Request) {
req.AddCookie(AdminCookie)
},
Name: "Delete subscription with users should fail",
WantResponse: http.StatusExpectationFailed,
WantDBData: map[string]interface{}{"name": "Basic"},
Assert: true,
Input: GenerateInputJSON(
customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Name = "Basic"
subscription.ID = basicSub.ID
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",
WantResponse: http.StatusOK,
WantDBData: map[string]interface{}{"name": "Premium"},
Assert: false,
Input: GenerateInputJSON(
customizeSubscription(func(subscription models.Subscription) models.Subscription {
subscription.Name = "Premium"
subscription.ID = premiumSub.ID
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})
}

File diff suppressed because it is too large Load Diff

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

@@ -0,0 +1,205 @@
package middlewares
import (
"GoMembership/internal/config"
"GoMembership/internal/utils"
"GoMembership/pkg/logger"
"errors"
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
type Session struct {
UserID uint
ExpiresAt time.Time
}
var (
sessionDuration = 5 * 24 * time.Hour
jwtSigningMethod = jwt.SigningMethodHS256
jwtParser = jwt.NewParser(jwt.WithValidMethods([]string{jwtSigningMethod.Alg()}))
sessions = make(map[string]*Session)
)
func verifyAndRenewToken(tokenString string) (string, uint, error) {
if tokenString == "" {
logger.Error.Printf("empty tokenstring")
return "", 0, fmt.Errorf("Authorization token is required")
}
token, claims, err := ExtractContentFrom(tokenString)
if err != nil && !errors.Is(err, jwt.ErrTokenExpired) {
logger.Error.Printf("Couldn't parse JWT token String: %v", err)
return "", 0, err
}
if token.Valid {
// 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]
if !ok {
logger.Error.Printf("session not found")
return "", 0, fmt.Errorf("session not found")
}
if userID != session.UserID {
return "", 0, fmt.Errorf("Cookie has been altered, aborting..")
}
if time.Now().After(sessions[sessionID].ExpiresAt) {
delete(sessions, sessionID)
logger.Error.Printf("session expired")
return "", 0, fmt.Errorf("session expired")
}
session.ExpiresAt = time.Now().Add(sessionDuration)
logger.Error.Printf("Session still valid generating new token")
// Session is still valid, generate a new token
user := map[string]interface{}{"user_id": userID, "role_id": roleID}
newTokenString, err := GenerateToken(&config.Auth.JWTSecret, user, sessionID)
if err != nil {
return "", 0, err
}
return newTokenString, session.UserID, nil
}
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
tokenString, err := c.Cookie("jwt")
if err != nil {
logger.Error.Printf("No Auth token: %v\n", err)
c.JSON(http.StatusUnauthorized,
gin.H{"errors": []gin.H{{
"field": "server.general",
"key": "server.error.no_auth_token",
}}})
c.Abort()
return
}
newToken, userID, err := verifyAndRenewToken(tokenString)
if err != nil {
logger.Error.Printf("Token(%v) is invalid: %v\n", tokenString, err)
c.JSON(http.StatusUnauthorized,
gin.H{"errors": []gin.H{{
"field": "server.general",
"key": "server.error.no_auth_token",
}}})
c.Abort()
return
}
if newToken != tokenString {
utils.SetCookie(c, newToken)
}
c.Set("user_id", uint(userID))
c.Next()
}
}
// 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 == "" {
sessionID = uuid.New().String()
}
claims["session_id"] = sessionID
claims["exp"] = time.Now().Add(time.Minute * 1).Unix() // Token expires in 10 Minutes
token := jwt.NewWithClaims(jwtSigningMethod, jwt.MapClaims(claims))
userID, ok := claims["user_id"].(uint)
if !ok {
return "", fmt.Errorf("invalid user_id in claims")
}
UpdateSession(sessionID, userID)
return token.SignedString([]byte(*jwtKey))
}
func ExtractContentFrom(tokenString string) (*jwt.Token, *jwt.MapClaims, error) {
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) {
logger.Error.Printf("Error parsing token: %v", err)
return nil, nil, err
}
// Ensure token is not nil (e.g., malformed tokens)
if token == nil {
logger.Error.Print("Token is nil after parsing")
return nil, nil, fmt.Errorf("invalid token")
}
// Extract and validate claims
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
logger.Error.Print("Invalid token claims structure")
return nil, nil, 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 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) {
sessions[sessionID] = &Session{
UserID: userID,
ExpiresAt: time.Now().Add(sessionDuration),
}
}
func InvalidateSession(token string) (bool, error) {
claims := jwt.MapClaims{}
_, err := jwt.ParseWithClaims(
token,
claims,
func(token *jwt.Token) (interface{}, error) {
return config.Auth.JWTSecret, nil
},
)
if err != nil {
return false, fmt.Errorf("Couldn't get JWT claims: %#v", err)
}
sessionID, ok := claims["session_id"].(string)
if !ok {
return false, fmt.Errorf("No SessionID found")
}
delete(sessions, sessionID)
return true, nil
}

View File

@@ -0,0 +1,193 @@
package middlewares
import (
"GoMembership/internal/config"
"GoMembership/internal/constants"
"GoMembership/pkg/logger"
"encoding/json"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
)
func setupTestEnvironment() {
cwd, err := os.Getwd()
if err != nil {
log.Fatalf("Failed to get current working directory: %v", err)
}
configFilePath := filepath.Join(cwd, "..", "..", "configs", "config.json")
templateHTMLPath := filepath.Join(cwd, "..", "..", "templates", "html")
templateMailPath := filepath.Join(cwd, "..", "..", "templates", "email")
if err := os.Setenv("TEMPLATE_MAIL_PATH", templateMailPath); err != nil {
log.Fatalf("Error setting environment variable: %v", err)
}
if err := os.Setenv("TEMPLATE_HTML_PATH", templateHTMLPath); err != nil {
log.Fatalf("Error setting environment variable: %v", err)
}
if err := os.Setenv("CONFIG_FILE_PATH", configFilePath); err != nil {
log.Fatalf("Error setting environment variable: %v", err)
}
config.LoadConfig()
logger.Info.Printf("Config: %#v", config.CFG)
}
func TestAuthMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
setupTestEnvironment()
tests := []struct {
name string
setupAuth func(r *http.Request)
expectedStatus int
expectNewCookie bool
expectedUserID uint
}{
{
name: "Valid Token",
setupAuth: func(r *http.Request) {
claims := map[string]interface{}{"user_id": uint(123), "role_id": constants.Roles.Member}
token, err := GenerateToken(&config.Auth.JWTSecret, claims, "")
if err != nil {
t.Fatal(err)
}
r.AddCookie(&http.Cookie{Name: "jwt", Value: token})
},
expectedStatus: http.StatusOK,
expectedUserID: 123,
},
{
name: "Missing Cookie",
setupAuth: func(r *http.Request) {},
expectedStatus: http.StatusUnauthorized,
expectedUserID: 0,
},
{
name: "Invalid Token",
setupAuth: func(r *http.Request) {
r.AddCookie(&http.Cookie{Name: "jwt", Value: "InvalidToken"})
},
expectedStatus: http.StatusUnauthorized,
expectedUserID: 0,
},
{
name: "Expired Token with Valid Session",
setupAuth: func(r *http.Request) {
sessionID := "test-session"
token := jwt.NewWithClaims(jwtSigningMethod, jwt.MapClaims{
"user_id": uint(123),
"role_id": constants.Roles.Member,
"session_id": sessionID,
"exp": time.Now().Add(-time.Hour).Unix(), // Expired 1 hour ago
})
tokenString, _ := token.SignedString([]byte(config.Auth.JWTSecret))
r.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
UpdateSession(sessionID, 123) // Add a valid session
},
expectedStatus: http.StatusOK,
expectNewCookie: true,
expectedUserID: 123,
},
{
name: "Expired Token with Expired Session",
setupAuth: func(r *http.Request) {
sessionID := "expired-session"
token := jwt.NewWithClaims(jwtSigningMethod, jwt.MapClaims{
"user_id": uint(123),
"role_id": constants.Roles.Member,
"session_id": sessionID,
"exp": time.Now().Add(-time.Hour).Unix(), // Expired 1 hour ago
})
tokenString, _ := token.SignedString([]byte(config.Auth.JWTSecret))
r.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
// Don't add a session, simulating an expired session
},
expectedStatus: http.StatusUnauthorized,
expectedUserID: 0,
},
{
name: "Invalid Signature",
setupAuth: func(r *http.Request) {
token := jwt.NewWithClaims(jwtSigningMethod, jwt.MapClaims{
"user_id": uint(123),
"session_id": "some-session",
"exp": time.Now().Add(time.Hour).Unix(),
})
tokenString, _ := token.SignedString([]byte("wrong_secret"))
r.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
},
expectedStatus: http.StatusUnauthorized,
expectedUserID: 0,
},
{
name: "Invalid Signing Method",
setupAuth: func(r *http.Request) {
token := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{
"user_id": uint(123),
"session_id": "some-session",
"role_id": constants.Roles.Member,
"exp": time.Now().Add(time.Hour).Unix(),
})
tokenString, _ := token.SignedString([]byte(config.Auth.JWTSecret))
r.AddCookie(&http.Cookie{Name: "jwt", Value: tokenString})
},
expectedStatus: http.StatusUnauthorized,
expectedUserID: 0,
},
}
for _, tt := range tests {
logger.Error.Print("==============================================================")
logger.Error.Printf("Testing : %v", tt.name)
logger.Error.Print("==============================================================")
t.Run(tt.name, func(t *testing.T) {
// Setup
r := gin.New()
r.Use(AuthMiddleware())
r.GET("/test", func(c *gin.Context) {
userID, exists := c.Get("user_id")
if exists {
c.JSON(http.StatusOK, gin.H{"user_id": userID})
} else {
c.JSON(http.StatusUnauthorized, gin.H{"user_id": 0})
}
})
req, _ := http.NewRequest(http.MethodGet, "/test", nil)
tt.setupAuth(req)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
assert.Equal(t, tt.expectedStatus, w.Code)
if tt.expectedStatus == http.StatusOK {
var response map[string]uint
err := json.Unmarshal(w.Body.Bytes(), &response)
assert.NoError(t, err)
assert.Equal(t, tt.expectedUserID, response["user_id"])
// Check if a new cookie was set
cookies := w.Result().Cookies()
if tt.expectNewCookie {
assert.GreaterOrEqual(t, len(cookies), 1)
assert.Equal(t, "jwt", cookies[0].Name)
assert.NotEmpty(t, cookies[0].Value)
} else {
assert.Equal(t, 0, len(cookies), "Unexpected cookie set")
}
} else {
assert.Equal(t, 0, len(w.Result().Cookies()))
}
})
}
}

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.BaseURL, origin: "http://localhost:8080",
expectedStatus: http.StatusOK, expectedStatus: http.StatusOK,
expectedHeaders: map[string]string{ expectedHeaders: map[string]string{
"Access-Control-Allow-Origin": config.BaseURL, "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) {

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