Compare commits

...

203 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
172 changed files with 16428 additions and 4542 deletions

6
.gitignore vendored
View File

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

View File

@@ -1,6 +1,20 @@
# Frontend
# sv
## Run locally
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:
@@ -10,3 +24,15 @@ 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'
});

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

@@ -1,83 +1,137 @@
// See https://kit.svelte.dev/docs/types#app
interface Subscription {
id: number | -1;
name: string | "";
details?: string | "";
conditions?: string | "";
monthly_fee?: number | -1;
hourly_rate?: number | -1;
included_hours_per_year?: number | 0;
included_hours_per_month?: number | 0;
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_model: Subscription;
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 | "";
id: number | -1;
mandate_date_signed: string | '';
bank: string | '';
account_holder_name: string | '';
iban: string | '';
bic: string | '';
mandate_reference: string | '';
}
interface DriversLicence {
id: number | -1;
status: number | -1;
licence_number: string | "";
issued_date: string | "";
expiration_date: string | "";
country: string | "";
licence_categories: LicenceCategory[];
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 | "";
id: number | -1;
category: string | '';
}
interface User {
email: string | "";
first_name: string | "";
last_name: string | "";
phone: string | "";
notes: string | "";
address: string | "";
zip_code: string | "";
city: string | "";
status: number | -1;
id: number | -1;
role_id: number | -1;
date_of_birth: string | "";
company: string | "";
profile_picture: string | "";
payment_status: number | -1;
membership: Membership;
bank_account: BankAccount;
drivers_licence: DriversLicence;
notes: string | "";
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;
subscriptions: Subscription[];
licence_categories: LicenceCategory[];
}
// interface PageData {}
// interface Platform {}
}
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 {};

View File

@@ -1,15 +1,23 @@
<!DOCTYPE html>
<html lang="en">
<!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" />
<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">

View File

@@ -1,75 +1,59 @@
import { BASE_API_URI } from "$lib/utils/constants.js";
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
return await resolve(event);
}
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");
// 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}/backend/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);
}
// find the user based on the 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();
const data = await response.json();
// Check if the server sent a new token
const newToken = response.headers.get("Set-Cookie");
if (newToken) {
const match = newToken.match(/jwt=([^;]+)/);
if (match) {
event.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
});
}
}
// Check if the server sent a new token
const newToken = response.headers.get('Set-Cookie');
refreshCookie(newToken, event.cookies);
event.locals.subscriptions = data.subscriptions;
event.locals.user = data.user;
event.locals.licence_categories = data.licence_categories;
console.dir(event.locals.licence_categories);
if (event.locals.user.date_of_birth) {
event.locals.user.date_of_birth =
event.locals.user.date_of_birth.split("T")[0];
}
if (event.locals.user.membership) {
if (event.locals.user.membership.start_date) {
event.locals.user.membership.start_date =
event.locals.user.membership.start_date.split("T")[0];
}
if (event.locals.user.membership.end_date) {
event.locals.user.membership.end_date =
event.locals.user.membership.end_date.split("T")[0];
}
}
if (
event.locals.user.bank_account &&
event.locals.user.bank_account.mandate_date_signed
) {
event.locals.user.bank_account.mandate_date_signed =
event.locals.user.bank_account.mandate_date_signed.split("T")[0];
}
userDatesFromRFC3339(data.user);
// load page as normal
return await resolve(event);
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,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

@@ -1,24 +1,22 @@
<script>
// import Developer from "$lib/img/hero-image.png";
// import Developer from "$lib/img/hero-image.png";
const year = new Date().getFullYear();
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/Sirneij">
<span>Developed by</span>
<!-- <img
<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>
</a>
<span class="footer-copyright"
>&copy; {year} Alexander Stölting. All Rights Reserved.</span
>
</div>
</div>
<span class="footer-copyright">&copy; {year} Alexander Stölting. All Rights Reserved.</span>
</div>
</div>
</footer>

View File

@@ -1,59 +1,120 @@
<script>
import { onMount } from "svelte";
import { applyAction, enhance } from "$app/forms";
import { page } from "$app/stores";
// import Developer from "$lib/img/hero-image.png";
import Avatar from "$lib/img/TeamAvatar.jpeg";
onMount(() => {
console.log("Page data in Header:", $page);
});
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';
$: {
console.log("Page data updated:", $page);
}
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">
<div class="header-left">
<div class="header-crafted-by-container">
<!-- <a href="https://tiny-bits.net/">
<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="header-right">
<div class="header-nav-item" class:active={$page.url.pathname === "/"}>
<a href="/">home</a>
</div>
{#if !$page.data.user}
<div
class="header-nav-item"
class:active={$page.url.pathname === "/auth/login"}
>
<a href="/auth/login">login</a>
</div>
<!-- <div
<!-- </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="/auth/about/{$page.data.user.id}">
<img
src={$page.data.user.profile_picture
? $page.data.user.profile_picture
: Avatar}
{: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}`}
/>
</a>
</div>
<!-- {#if $page.data.user.is_superuser}
/> -->
{$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")}
@@ -61,19 +122,277 @@
<a href="/auth/admin">admin</a>
</div>
{/if} -->
<form
class="header-nav-item"
action="/auth/logout"
method="POST"
use:enhance={async () => {
return async ({ result }) => {
await applyAction(result);
};
}}
>
<button type="submit">logout</button>
</form>
{/if}
</div>
</div>
<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

@@ -1,240 +1,343 @@
<script>
import { createEventDispatcher } from "svelte";
import { t } from "svelte-i18n";
import { t } from 'svelte-i18n';
/** @type {string} */
export let name;
/** @type {string} */
export let name;
/** @type {string} */
export let type = "text";
/** @type {string} */
export let type = 'text';
/** @type {string|Number|null} */
export let value;
/** @type {string|Number|null} */
export let value;
/** @type {string} */
export let placeholder = "";
/** @type {string} */
export let placeholder = '';
/** @type {Number} */
export let rows = 4;
/** @type {Number} */
export let rows = 4;
/** @type {Array<{value: string | number, label: string, color?:string}>} */
export let options = [];
/** @type {Array<{value: string | number, label: string, color?:string}>} */
export let options = [];
/** @type {Boolean} */
export let required = false;
/** @type {Boolean} */
export let required = false;
/** @type {string} */
export let label = "";
/** @type {string} */
export let label = '';
/** @type {string} */
export let otherPasswordValue = "";
/** @type {string} */
export let otherPasswordValue = '';
/** @type {boolean} */
export let toUpperCase = false;
/** @type {boolean} */
export let toUpperCase = false;
/** @type {boolean} */
export let checked = false;
/** @type {boolean} */
export let checked = false;
/**
* @param {Event} event - The input event
*/
function handleInput(event) {
const target = event.target;
/** @type {boolean} */
export let readonly = false;
if (target instanceof HTMLInputElement) {
let inputValue = target.value;
if (toUpperCase) {
inputValue = inputValue.toUpperCase();
target.value = inputValue; // Update the input field value
}
value = inputValue;
}
}
/** @type {string} */
export let backgroundColor = '--surface0';
/**
* 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;
switch (name) {
case "membership_start_date":
return typeof value === "string" && value.trim()
? null
: $t("validation.date");
case "email":
return typeof value === "string" && /^\S+@\S+\.\S+$/.test(value)
? null
: $t("validation.email");
case "password":
case "password2":
if (typeof value === "string" && value.length < 8) {
return $t("validation.password");
}
if (otherPasswordValue && value !== otherPasswordValue) {
return $t("validation.password_match");
}
return null;
case "phone":
return typeof value === "string" && /^\+?[0-9\s()-]{7,}$/.test(value)
? null
: $t("validation.phone");
case "zip_code":
return typeof value === "string" && /^\d{5}$/.test(value)
? null
: $t("validation.zip_code");
case "iban":
return typeof value === "string" &&
/^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(value)
? null
: $t("validation.iban");
case "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");
case "licence_number":
return typeof value === "string" && value.length == 11
? null
: $t("validation.drivers_licence");
/**
* @param {Event} event - The input event
*/
function handleInput(event) {
const target = event.target;
default:
return typeof value === "string" && !value.trim() && required
? $t("validation.required")
: null;
}
}
if (target instanceof HTMLInputElement) {
let inputValue = target.value;
if (toUpperCase) {
inputValue = inputValue.toUpperCase();
}
target.value = inputValue; // Update the input field value
value = inputValue.trim();
}
}
$: error = validateField(name, value, required);
$: selectedOption = options.find((option) => option.value == value);
$: selectedColor = selectedOption ? selectedOption.color : "";
/**
* 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' : ''}">
{#if type === "checkbox"}
<label class="checkbox-label">
<input
type="checkbox"
{name}
{value}
{checked}
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 type === "select"}
<select
{name}
bind:value
{required}
class="input select"
style={selectedColor ? `color: ${selectedColor};` : ""}
>
{#each options as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
{:else if type === "textarea"}
<textarea
{name}
{placeholder}
{required}
{value}
{rows}
class="input textarea"
style="height:{rows * 1.5}em;"
/>
{:else if type != "checkbox"}
<input
{name}
{type}
{placeholder}
{value}
{required}
on:input={handleInput}
on:blur={handleInput}
class="input"
/>
{/if}
</div>
<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>
.checkbox-container {
display: inline-flex;
align-items: center;
background-color: transparent;
}
:root {
--form-control-color: var(--green); /* Changed from #6bff55 */
--form-control-disabled: var(--subtext1); /* Changed from #959495 */
}
.checkbox-label {
display: inline-flex;
align-items: center;
cursor: pointer;
user-select: none;
justify-content: center;
}
.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);
}
.checkbox-label input[type="checkbox"] {
margin-right: 5px;
flex-shrink: 0;
width: 40px;
}
.form-control--disabled {
color: var(--form-control-disabled);
cursor: not-allowed;
}
.checkbox-text {
font-size: 16px;
}
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;
}
@media (min-width: 768px) {
.checkbox-text {
flex-direction: row;
align-items: center;
}
}
.select {
padding-right: 1.5em;
}
.input-error-container {
display: flex;
align-items: flex-end;
width: 100%;
max-width: 444px;
}
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);
}
.error-message {
color: #eb5424;
font-size: 12px;
margin-bottom: 5px;
align-self: flex-start;
}
input[type='checkbox']:checked::before {
transform: scale(1);
}
.input {
width: 100%;
}
input,
textarea,
select {
width: 100%;
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
color: white;
}
textarea {
resize: vertical;
min-height: 100px;
}
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

@@ -1,128 +1,93 @@
<script>
import { quintOut } from "svelte/easing";
import { quintOut } from 'svelte/easing';
import { t } from "svelte-i18n";
import { createEventDispatcher } from "svelte";
const modal = (/** @type {Element} */ node, { duration = 300 } = {}) => {
const transform = getComputedStyle(node).transform;
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:
return {
duration,
easing: quintOut,
css: (/** @type {any} */ t, /** @type {number} */ u) => {
return `transform:
${transform}
scale(${t})
translateY(${u * -100}%)
`;
},
};
};
const dispatch = createEventDispatcher();
function closeModal() {
dispatch("close", {});
}
}
};
};
</script>
<div class="modal-background">
<div
transition:modal={{ duration: 1000 }}
class="modal"
role="dialog"
aria-modal="true"
>
<!-- svelte-ignore a11y-missing-attribute -->
<a
title={$t("cancel")}
class="modal-close"
on:click={closeModal}
role="button"
tabindex="0"
on:keydown={(e) => e.key == "Enter" && closeModal()}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 384 512"
aria-hidden="true"
>
<path
d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"
/>
</svg>
<span class="sr-only">{$t("cancel")}</span>
</a>
<div class="container">
<slot />
</div>
</div>
<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: rgba(0, 0, 0, 0.9);
z-index: 9999;
display: flex;
}
.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%;
box-shadow: 0 0 10px hsl(0 0% 0% / 10%);
transform: translate(-50%, -50%);
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
@media (max-width: 990px) {
.modal {
width: 90%;
}
}
.modal-close {
border: none;
}
.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;
}
.modal-close svg {
display: block;
margin-left: auto;
margin-right: auto;
fill: rgb(14 165 233 /1);
transition: all 0.5s;
}
.modal-close:hover svg {
fill: rgb(225 29 72);
transform: scale(1.5);
}
.modal .container {
max-height: 90vh;
overflow-y: auto;
align-items: center;
}
@media (min-width: 680px) {
.modal .container {
flex-direction: column;
left: 0;
width: 100%;
}
}
/* 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

@@ -1,24 +1,24 @@
<script>
/** @type {number | null} */
export let width;
/** @type {string | null} */
export let message;
/** @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` : ""} />
{#if message}
<p>{message}</p>
{/if}
<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;
}
.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

@@ -8,7 +8,7 @@
</script>
{#key key}
<div in:slide={{ duration, delay: duration }} out:slide={{ duration }}>
<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

@@ -1,46 +1,144 @@
: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-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");
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;
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;
max-width: 1200px;
margin: 5em auto 0 auto;
}
pre,
code {
display: inline;
font-family: "Roboto Mono", monospace;
font-size: 16px;
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;
font-family: 'Roboto Mono', monospace;
color: var(--text);
border-style: none;
height: 21px;
font-size: 16px;
}
button {
font-size: 16px;
font-size: 16px;
}
h1,
h2,
@@ -48,466 +146,408 @@ h3,
h4,
h5,
h6 {
margin: 0;
font-weight: normal;
margin: 0;
font-weight: normal;
}
h2 {
margin: 0 0 45px 0;
color: #fff;
font-size: 36px;
margin: 0 0 45px 0;
color: var(--lavender);
font-size: 36px;
}
h3 {
margin: 0 0 2rem 0;
color: #fff;
font-size: 32px;
margin: 0 0 2rem 0;
color: var(--lavender);
font-size: 32px;
}
p {
margin: 0 0 45px;
line-height: 1.8;
margin: 0 0 45px;
line-height: 1.8;
}
ul {
margin: 0 0 32px;
margin: 0 0 32px;
}
a {
transition: border 0.2s ease-in-out;
border-bottom: 1px solid transparent;
text-decoration: none;
color: #00b7ef;
transition: border 0.2s ease-in-out;
border-bottom: 1px solid transparent;
text-decoration: none;
color: var(--blue);
}
a:hover {
border-bottom-color: #00b7ef;
border-bottom-color: var(--blue);
}
li {
line-height: 1.8;
line-height: 1.8;
}
li strong {
color: #fff;
color: var(--text);
}
.image {
width: 100%;
margin: 0 0 32px;
padding: 0;
width: 100%;
margin: 0 0 32px;
padding: 0;
}
.image img {
width: 100%;
width: 100%;
}
.optanon-alert-box-wrapper {
left: 0;
left: 0;
}
.hidden {
display: none !important;
display: none !important;
}
.hide-mobile {
display: none;
display: none;
}
@media (min-width: 680px) {
body {
margin: 8em auto 0 auto;
}
.hide-mobile {
display: initial;
}
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;
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: #fff;
border-color: var(--text);
}
.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;
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: #c907ff;
border-color: #c907ff;
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: white;
text-transform: uppercase;
font-weight: 500;
padding: 18px 28px;
letter-spacing: 1px;
cursor: pointer;
background-color: #eb5424;
border: 1px solid #eb5424;
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: #ca3f12;
border-color: #ca3f12;
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: white;
text-transform: uppercase;
font-weight: 500;
padding: 18px 28px;
letter-spacing: 1px;
cursor: pointer;
background-color: #9a9a9a;
border: 1px solid #9a9a9a;
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;
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;
margin-top: 88px;
margin-bottom: 32px;
}
.hero-container .hero-subtitle {
font-size: 20px;
text-align: center;
line-height: 32px;
margin: 0 0 45px 0;
font-size: 20px;
text-align: center;
line-height: 32px;
margin: 0 0 45px 0;
}
.hero-container .hero-buttons-container {
display: flex;
display: flex;
}
.hero-container .hero-buttons-container button {
margin: 0 8px;
margin: 0 8px;
}
@media (min-width: 680px) {
.hero-container {
margin: 0 auto 140px auto;
}
.hero-container {
margin: 0 auto 140px auto;
}
}
.container {
transition: opacity 0.2s ease-in-out;
color: white;
letter-spacing: 0;
opacity: 1;
transition: opacity 0.2s ease-in-out;
color: var(--white);
letter-spacing: 0;
opacity: 1;
}
.container .content {
width: 100%;
flex-grow: 1;
width: 100%;
flex-grow: 1;
}
.container .content .step-title {
color: #fff;
font-size: 20px;
font-weight: 500;
line-height: 86px;
opacity: 1;
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;
position: relative;
top: -5px;
font-size: 16px;
font-weight: 300;
}
@media (max-width: 680px) {
.container .content {
margin-top: 120px;
}
.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;
}
.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;
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;
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;
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;
}
.input-box {
padding: 10px;
}
}
.btn-container {
display: flex;
justify-content: space-between;
align-items: first baseline;
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;
}
.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: rgb(255 228 230);
border: 1px solid rgb(225 29 72);
border-radius: 6px;
color: rgb(225 29 72);
font-size: 16px;
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: rgb(225 29 72);
text-decoration: underline;
color: var(--red);
text-decoration: underline;
}
.warning.hidden {
display: none;
display: none;
}
.error {
margin-top: 10rem;
padding: 30px 40px;
background: #2f3132;
color: #fff;
margin-top: 10rem;
padding: 30px 40px;
background: var(--surface0);
color: var(--text);
}
.error p {
margin: 0 0 1rem;
margin: 0 0 1rem;
}
.error p.intro {
font-size: 1.3rem;
font-size: 1.3rem;
}
.error .button-colorful {
display: inline-block;
display: inline-block;
}
@media (min-width: 680px) {
.error {
padding: 65px 80px;
}
.error {
padding: 65px 80px;
}
}
.footer-branding-container {
color: white;
font-weight: 300;
margin-bottom: 73px;
color: var(--white);
font-weight: 300;
margin-bottom: 73px;
}
.footer-branding-container .footer-branding {
display: flex;
width: 400px;
display: flex;
width: 400px;
}
.footer-branding-container .footer-branding {
flex-direction: column;
text-align: center;
margin: 30px 0 0;
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;
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;
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
.footer-crafted-by-container
.footer-branded-crafted-img {
height: 28px;
}
.footer-branding-container .footer-branding .footer-copyright {
color: #696969;
letter-spacing: 0.5px;
color: var(--overlay0);
letter-spacing: 0.5px;
}
.footer-container {
width: 100%;
color: white;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
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;
}
.footer-container {
padding: 0;
}
}
.simple-loader {
--b: 20px; /* border thickness */
--n: 15; /* number of dashes*/
--g: 7deg; /* gap between dashes*/
--c: #d43aff; /* the color */
--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)),
#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));
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);
}
to {
transform: rotate(1turn);
}
}

View File

@@ -1,111 +1,264 @@
export default {
userStatus: {
1: "Nicht verifiziert",
2: "Verifiziert",
3: "Aktiv",
4: "Passiv",
5: "Deaktiviert",
},
userRole: {
0: "Mitglied",
1: "Betrachter",
4: "Bearbeiter",
8: "Administrator",
},
placeholder: {
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",
},
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",
drivers_licence: "Nummer zu kurz(11 Zeichen)",
},
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",
},
cancel: "Abbrechen",
confirm: "Bestätigen",
licence_categories: "Führerscheinklassen",
subscription_model: "Mitgliedschatfsmodell",
licence: "Führerschein",
licence_number: "Führerscheinnummer",
issued_date: "Ausgabedatum",
expiration_date: "Ablaufdatum",
country: "Land",
monthly_fee: "Monatliche Gebühr",
hourly_rate: "Stundensatz",
details: "Details",
conditions: "Bedingungen",
user_role: "Nutzerrolle",
unknown: "Unbekannt",
notes: "Notizen",
address: "Straße & Hausnummer",
city: "Wohnort",
zip_code: "PLZ",
forgot_password: "Passwort vergessen?",
password: "Passwort",
password_repeat: "Passwort wiederholen",
email: "Email",
company: "Firma",
login: "Anmelden",
user: "Nutzer",
user_login: "Nutzer Anmeldung",
user_edit: "Nutzer bearbeiten",
profile: "Profil",
membership: "Mitgliedschaft",
bankaccount: "Kontodaten",
first_name: "Vorname",
last_name: "Nachname",
phone: "Telefonnummer",
birth_date: "Geburtstag",
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",
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

@@ -1,15 +1,210 @@
export default {
userStatus: {
1: "Unverified",
2: "Verified",
3: "Active",
4: "Passive",
5: "Disabled",
},
userRole: {
0: "Member",
1: "Viewer",
4: "Editor",
8: "Admin",
},
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

@@ -1,3 +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;
? 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

@@ -1,24 +1,23 @@
// @ts-nocheck
import { quintOut } from "svelte/easing";
import { crossfade } from "svelte/transition";
import { quintOut } from 'svelte/easing';
import { crossfade } from 'svelte/transition';
export const [send, receive] = crossfade({
duration: (d) => Math.sqrt(d * 200),
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;
// 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) => `
return {
duration: 600,
easing: quintOut,
css: (t) => `
transform: ${transform} scale(${t});
opacity: ${t}
`,
};
},
`
};
}
});
/**
@@ -27,9 +26,9 @@ export const [send, receive] = crossfade({
* @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());
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
@@ -37,11 +36,9 @@ export const isValidEmail = (email) => {
* @param {string} password - The password to validate
*/
export const isValidPasswordStrong = (password) => {
const strongRegex = new RegExp(
"^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})"
);
const strongRegex = new RegExp('^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})');
return strongRegex.test(password.trim());
return strongRegex.test(password.trim());
};
/**
* Validates a medium password field
@@ -49,11 +46,11 @@ export const isValidPasswordStrong = (password) => {
* @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,})"
);
const mediumRegex = new RegExp(
'^(((?=.*[a-z])(?=.*[A-Z]))|((?=.*[a-z])(?=.*[0-9]))|((?=.*[A-Z])(?=.*[0-9])))(?=.{6,})'
);
return mediumRegex.test(password.trim());
return mediumRegex.test(password.trim());
};
/**
@@ -63,43 +60,178 @@ export const isValidPasswordMedium = (password) => {
*/
export function isEmpty(obj) {
for (const _i in obj) {
return false;
}
return true;
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);
});
}
/**
* Test whether or not an object is empty.
* @param {any} obj - The object to test
* @returns `true` or `false`
*
* @param {App.Locals['user']} user - The user object to format
*/
export function formatError(obj) {
const errors = [];
if (typeof obj === "object" && obj !== null) {
if (Array.isArray(obj)) {
obj.forEach((/** @type {Object} */ error) => {
Object.keys(error).map((k) => {
errors.push({
error: error[k],
id: Math.random() * 1000,
});
});
});
} else {
Object.keys(obj).map((k) => {
errors.push({
error: obj[k],
id: Math.random() * 1000,
});
});
}
} else {
errors.push({
error: obj.charAt(0).toUpperCase() + obj.slice(1),
id: 0,
});
}
return errors;
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,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

@@ -1,118 +0,0 @@
// @ts-nocheck
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;
}
/**
* @typedef {Object} FormattedError
* @property {string} error - The error message
* @property {number} id - A unique identifier for the error
*/
/**
* Format Error object(s)
* @param {any} obj - The object to test
* @returns @type {FormattedError[]}
*/
export function formatError(obj) {
/** @type {FormattedError[]} */
const errors = [];
if (typeof obj === "object" && obj !== null) {
if (Array.isArray(obj)) {
obj.forEach((/** @type {Object} */ error) => {
Object.keys(error).map((k) => {
errors.push({
error: error[k],
id: Math.random() * 1000,
});
});
});
} else {
Object.keys(obj).map((k) => {
errors.push({
error: obj[k],
id: Math.random() * 1000,
});
});
}
} else {
errors.push({
error: obj.charAt(0).toUpperCase() + obj.slice(1),
id: 0,
});
}
return errors;
}
export function toRFC3339(dateString) {
if (!dateString) return "";
const date = new Date(dateString);
return date.toISOString();
}

View File

@@ -1,5 +1,7 @@
/** @type {import('./$types').LayoutLoad} */
export async function load({ fetch, url, data }) {
const { user, subscriptions, licence_categories } = 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

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

View File

@@ -8,6 +8,7 @@
import { onMount } from "svelte";
import "$lib/utils/i18n.js";
// import "$lib/css/bootstrap-custom.scss";
/** @type {import('./$types').PageData} */
export let data;

View File

@@ -3,11 +3,11 @@
</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://tiny-bits.net/" data-learn-more
>Auf zu Tiny Bits</a
>
</div>
<!-- <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

@@ -1,186 +1,146 @@
import { BASE_API_URI } from "$lib/utils/constants";
import { formatError } from "$lib/utils/helpers";
import { fail, redirect } from "@sveltejs/kit";
import { toRFC3339 } from "$lib/utils/utils";
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, `/auth/login?next=/auth/about/${params.id}`);
}
// 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();
/**
*
* @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();
/** @type {Partial<App.Locals['user']>} */
const updateData = {
id: Number(formData.get("id")),
first_name: String(formData.get("first_name")),
last_name: String(formData.get("last_name")),
email: String(formData.get("email")),
phone: String(formData.get("phone")),
notes: String(formData.get("notes")),
address: String(formData.get("address")),
zip_code: String(formData.get("zip_code")),
city: String(formData.get("city")),
date_of_birth: toRFC3339(formData.get("birth_date")),
company: String(formData.get("company")),
profile_picture: String(formData.get("profile_picture")),
membership: {
id: Number(formData.get("membership_id")),
start_date: toRFC3339(formData.get("membership_start_date")),
end_date: toRFC3339(formData.get("membership_end_date")),
status: Number(formData.get("membership_status")),
parent_member_id: Number(formData.get("parent_member_id")),
subscription_model: {
id: Number(formData.get("subscription_model_id")),
name: String(formData.get("subscription_model_name")),
},
},
bank_account: {
id: Number(formData.get("bank_account_id")),
mandate_date_signed: String(formData.get("mandate_date_signed")),
bank: String(formData.get("bank")),
account_holder_name: String(formData.get("account_holder_name")),
iban: String(formData.get("iban")),
bic: String(formData.get("bic")),
mandate_reference: String(formData.get("mandate_reference")),
},
drivers_licence: {
id: Number(formData.get("drivers_licence_id")),
status: Number(formData.get("licence_status")),
licence_number: String(formData.get("licence_number")),
issued_date: toRFC3339(formData.get("issued_date")),
expiration_date: toRFC3339(formData.get("expiration_date")),
country: String(formData.get("country")),
licence_categories: formData
.getAll("licence_categories[]")
.map((category) => ({
id: -1, // Use -1 as a placeholder for new categories
category: String(category),
})),
},
};
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);
// Remove undefined or null properties
const cleanUpdateData = Object.fromEntries(
Object.entries(updateData).filter(([_, v]) => v != null)
);
console.dir(cleanUpdateData);
const apiURL = `${BASE_API_URI}/backend/users/update/`;
const res = await fetch(apiURL, {
method: "PATCH",
credentials: "include",
headers: {
"Content-Type": "application/json",
Cookie: `jwt=${cookies.get("jwt")}`,
},
body: JSON.stringify(cleanUpdateData),
});
// const isCreating = !processedData.user.id || processedData.user.id === 0;
// console.log('Is creating: ', isCreating);
const apiURL = `${BASE_API_URI}/auth/users/`;
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.error);
return fail(400, { errors: errors });
}
/** @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);
const response = await res.json();
locals.user = response;
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.errors);
return fail(400, { errors: errors });
}
// Format dates
if (locals.user.date_of_birth) {
locals.user.date_of_birth = response.date_of_birth.split("T")[0];
}
if (locals.user.membership?.start_date) {
locals.user.membership.start_date =
locals.user.membership.start_date.split("T")[0];
}
if (locals.user.membership?.end_date) {
locals.user.membership.end_date =
locals.user.membership.end_date.split("T")[0];
}
const response = await res.json();
locals.user = response;
userDatesFromRFC3339(locals.user);
throw redirect(303, `${base}/auth/about/${response.id}`);
},
throw redirect(303, `/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();
/**
*
* @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,
};
/** @type {RequestInit} */
const requestInitOptions = {
method: 'POST',
headers: {
Cookie: `jwt=${cookies.get('jwt')}`
},
body: formData
};
const res = await fetch(`${BASE_API_URI}/file/upload/`, requestInitOptions);
const res = await fetch(`${BASE_API_URI}/file/upload/`, requestInitOptions);
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.error);
return fail(400, { errors: errors });
}
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.errors);
return fail(400, { errors: errors });
}
const response = await res.json();
const response = await res.json();
return {
success: true,
profile_picture: response[""],
};
},
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();
/**
*
* @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,
};
/** @type {RequestInit} */
const requestInitOptions = {
method: 'DELETE',
headers: {
Cookie: `jwt=${cookies.get('jwt')}`
},
body: formData
};
const res = await fetch(`${BASE_API_URI}/file/delete/`, requestInitOptions);
const res = await fetch(`${BASE_API_URI}/file/delete/`, requestInitOptions);
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.error);
return fail(400, { errors: errors });
}
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.errors);
return fail(400, { errors: errors });
}
return {
success: true,
profile_picture: "",
};
},
return {
success: true,
profile_picture: ''
};
}
};

View File

@@ -1,835 +1,164 @@
<script>
import ImageInput from "$lib/components/ImageInput.svelte";
import InputField from "$lib/components/InputField.svelte";
import SmallLoader from "$lib/components/SmallLoader.svelte";
import Modal from "$lib/components/Modal.svelte";
import Avatar from "$lib/img/TeamAvatar.jpeg";
import { onMount } from "svelte";
import { applyAction, enhance } from "$app/forms";
import { page } from "$app/stores";
import { receive, send } from "$lib/utils/helpers";
import { t } from "svelte-i18n";
import { fly } from "svelte/transition";
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;
/** @type {import('./$types').ActionData} */
export let form;
/** @type {App.Locals['subscriptions']}*/
$: subscriptions = $page.data.subscriptions;
$: ({ user, licence_categories, subscriptions } = $page.data);
/** @type {App.Locals['user']}*/
$: user = $page.data.user;
let showModal = false;
/** @type {App.Locals['licence_categories']} */
$: licence_categories = $page.data.licence_categories;
const open = () => (showModal = true);
const close = () => {
showModal = false;
if (form) {
form.errors = undefined;
}
};
// /** @typedef {{name: string, src: string}} Avatar */
// const avatarFiles = import.meta.glob("$lib/img/Avatar-*.jpeg", {
// eager: true,
// });
// /** @type{Avatar[]} */
// let avatars = [];
const TABS = ["profile", "licence", "membership", "bankaccount"];
let activeTab = TABS[0];
$: subscriptionModelOptions = subscriptions.map((sub) => ({
value: sub?.name ?? "",
label: sub?.name ?? "",
}));
const userStatusOptions = [
{ value: 1, label: $t("userStatus.1"), color: "#b1b1b1" }, // Grey for "Nicht verifiziert"
{ value: 2, label: $t("userStatus.2"), color: "#90EE90" }, // Light green for "Verifiziert"
{ value: 3, label: $t("userStatus.3"), color: "#00bc00" }, // Green for "Aktiv"
{ value: 4, label: $t("userStatus.4"), color: "#FFC0CB" }, // Pink for "Passiv"
{ value: 5, label: $t("userStatus.5"), color: "#FF4646" }, // Red for "Deaktiviert"
];
const userRoleOptions = [
{ value: 0, label: $t("userRole.0"), color: "#b1b1b1" }, // Grey for "Mitglied"
{ value: 1, label: $t("userRole.1"), color: "#00bc00" }, // Green for "Betrachter"
{ value: 4, label: $t("userRole.4"), color: "#FFC0CB" }, // Pink for "Bearbeiter"
{ value: 8, label: $t("userRole.8"), color: "#FF4646" }, // Red for "Admin"
];
const membershipStatusOptions = [
{ value: 3, label: $t("userStatus.3"), color: "#00bc00" }, // Green for "Aktiv"
{ value: 4, label: $t("userStatus.4"), color: "#FFC0CB" }, // Pink for "Passiv"
{ value: 5, label: $t("userStatus.5"), color: "#FF4646" }, // Red for "Deaktiviert"
];
const licenceStatusOptions = [
{ value: 1, label: $t("userStatus.1"), color: "#b1b1b1" }, // Grey for "Nicht verifiziert"
{ value: 3, label: $t("userStatus.3"), color: "#00bc00" }, // Green for "Aktiv"
{ value: 4, label: $t("userStatus.4"), color: "#FFC0CB" }, // Pink for "Passiv"
{ value: 5, label: $t("userStatus.5"), color: "#FF4646" }, // Red for "Deaktiviert"
];
let showModal = false,
isUploading = false,
isUpdating = false,
// showAvatars = false,
password = "",
password2 = "";
const open = () => (showModal = true);
const close = () => (showModal = false);
// const toggleAvatars = () => (showAvatars = !showAvatars);
$: selectedSubscriptionModel =
subscriptions.find(
(sub) => sub?.id === user.membership?.subscription_model.id
) || null;
onMount(() => {
console.dir(user);
console.dir(licence_categories);
// avatars = Object.entries(avatarFiles).map(([path, module]) => {
// if (typeof path !== "string") {
// throw new Error("Unexpected non-string path");
// }
// if (
// typeof module !== "object" ||
// module === null ||
// !("default" in module)
// ) {
// throw new Error("Unexpected module format");
// }
// const src = module.default;
// if (typeof src !== "string") {
// throw new Error("Unexpected default export type");
// }
// return {
// name: path.split("/").pop()?.split(".")[0] ?? "Unknown",
// src: src,
// };
// });
});
/**
* Sets the active tab
* @param {string} tab - The tab to set as active
*/
function setActiveTab(tab) {
activeTab = tab;
}
/** @type {import('./$types').SubmitFunction} */
const handleUpdate = async ({ form, formData, action, cancel }) => {
isUpdating = true;
return async ({ result }) => {
isUpdating = false;
if (result.type === "success" || result.type === "redirect") {
close();
}
await applyAction(result);
};
};
/** @type {import('./$types').SubmitFunction} */
// const handleUpload = async () => {
// isUploading = true;
// return async ({ result }) => {
// isUploading = false;
// /** @type {any} */
// const res = result;
// if (result.type === "success" || result.type === "redirect") {
// user.profile_picture = res.data.profile_picture;
// }
// await applyAction(result);
// };
// };
onMount(() => {
console.dir(user);
});
</script>
<div class="hero-container">
<!-- <div class="hero-logo">
<img
src={user.profile_picture ? user.profile_picture : Avatar}
alt={`${user.first_name} ${user.last_name}`}
width="200"
/>
</div> -->
<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 role" })}</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.date_of_birth}
<h3 class="hero-subtitle subtitle info-row">
<span class="label">Geburtstag:</span>
<span class="value">{user.date_of_birth}</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="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 class="hero-buttons-container">
<button class="button-dark" on:click={open}>Ändern</button>
</div>
</div>
{#if showModal}
<Modal on:close={close}>
<!-- <div class="avatar-container">
<form
class="avatar-form"
action="?/uploadImage"
method="post"
enctype="multipart/form-data"
use:enhance={handleUpload}
>
<div class="current-avatar">
<ImageInput
avatar={user.profile_picture}
fieldName="profile_picture"
title="Nutzerbild auswählen"
/>
</div>
<div class="avatar-buttons">
{#if !user.profile_picture}
{#if isUploading}
<SmallLoader width={30} message={"Uploading..."} />
{:else}
<button class="button-dark" type="submit">Bild hochladen</button>
{/if}
{:else}
<input
type="hidden"
hidden
name="profile_picture_url"
bind:value={user.profile_picture}
required
/>
{#if isUploading}
<SmallLoader width={30} message={"Lösche..."} />
{:else}
<button
class="button-dark"
formaction="?/deleteImage"
type="submit"
>
Bild löschen
</button>
{/if}
{/if}
</div>
</form>
<div class="avatar-buttons">
<button class="button-dark" on:click={toggleAvatars}>
{showAvatars ? "Abbrechen" : "Profilbild auswählen"}
</button>
</div>
{#if showAvatars}
<div class="avatar-selection">
{#each avatars as avatar}
<button
class="avatar-option"
on:click={() => {
user.profile_picture = avatar.src;
showAvatars = false;
}}
>
<img src={avatar.src} alt={avatar.name} width="80" />
</button>
{/each}
</div>
{/if}
</div> -->
<form
class="content"
action="?/updateUser"
method="POST"
use:enhance={handleUpdate}
>
<input name="id" type="number" hidden bind:value={user.id} />
<h1 class="step-title" style="text-align: center;">{$t("user_edit")}</h1>
{#if form?.success}
<h4
class="step-subtitle warning"
in:receive={{ key: Math.floor(Math.random() * 100) }}
out:send={{ 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={{ key: error.id }}
out:send={{ key: error.id }}
>
{error.error}
</h4>
{/each}
{/if}
<input
type="hidden"
hidden
name="profile_picture"
bind:value={user.profile_picture}
/>
<div class="button-container">
{#each TABS as tab}
<button
type="button"
class="button-dark"
class:active={activeTab === tab}
on:click={() => setActiveTab(tab)}
>
{$t(tab)}
</button>
{/each}
</div>
<div
class="tab-content"
style="display: {activeTab === 'profile' ? 'block' : 'none'}"
>
<InputField
name="status"
type="select"
label={$t("status")}
bind:value={user.status}
options={userStatusOptions}
/>
{#if user.role_id === 8}
<InputField
name="role_id"
type="select"
label={$t("user_role")}
bind:value={user.role_id}
options={userRoleOptions}
/>
{/if}
<InputField
name="password"
type="password"
label={$t("password")}
placeholder={$t("placeholder.password")}
bind:value={password}
otherPasswordValue={password2}
/>
<InputField
name="password2"
type="password"
label={$t("password_repeat")}
placeholder={$t("placeholder.password")}
bind:value={password2}
otherPasswordValue={password}
/>
<InputField
name="first_name"
label={$t("first_name")}
bind:value={user.first_name}
placeholder={$t("placeholder.first_name")}
required={true}
/>
<InputField
name="last_name"
label={$t("last_name")}
bind:value={user.last_name}
placeholder={$t("placeholder.last_name")}
required={true}
/>
<InputField
name="company"
label={$t("company")}
bind:value={user.company}
placeholder={$t("placeholder.company")}
/>
<InputField
name="email"
type="email"
label={$t("email")}
bind:value={user.email}
placeholder={$t("placeholder.email")}
required={true}
/>
<InputField
name="phone"
type="tel"
label={$t("phone")}
bind:value={user.phone}
placeholder={$t("placeholder.phone")}
/>
<InputField
name="birth_date"
type="date"
label={$t("birth_date")}
bind:value={user.date_of_birth}
placeholder={$t("placeholder.birth_date")}
/>
<InputField
name="address"
label={$t("address")}
bind:value={user.address}
placeholder={$t("placeholder.address")}
/>
<InputField
name="zip_code"
label={$t("zip_code")}
bind:value={user.zip_code}
placeholder={$t("placeholder.zip_code")}
/>
<InputField
name="city"
label={$t("city")}
bind:value={user.city}
placeholder={$t("placeholder.city")}
/>
<InputField
name="notes"
type="textarea"
label={$t("notes")}
bind:value={user.notes}
placeholder={$t("placeholder.notes", {
values: { name: user.first_name || "" },
})}
rows={10}
/>
</div>
<div
class="tab-content"
style="display: {activeTab === 'licence' ? 'block' : 'none'}"
>
<InputField
name="licence_status"
type="select"
label={$t("status")}
bind:value={user.drivers_licence.status}
options={licenceStatusOptions}
/>
<InputField
name="licence_number"
type="text"
label={$t("licence_number")}
bind:value={user.drivers_licence.licence_number}
placeholder={$t("placeholder.licence_number")}
toUpperCase={true}
/>
<InputField
name="isued_date"
type="date"
label={$t("issued_date")}
bind:value={user.drivers_licence.issued_date}
placeholder={$t("placeholder.issued_date")}
/>
<InputField
name="expiration_date"
type="date"
label={$t("expiration_date")}
bind:value={user.drivers_licence.expiration_date}
placeholder={$t("placeholder.expiration_date")}
/>
<InputField
name="country"
label={$t("country")}
bind:value={user.drivers_licence.country}
placeholder={$t("placeholder.issuing_country")}
/>
<div class="licence-categories">
<h3>{$t("licence_categories")}</h3>
<div class="checkbox-grid">
{#each licence_categories as category}
<div class="checkbox-item">
<div class="checkbox-label-container">
<InputField
type="checkbox"
name="licence_categories[]"
value={category.category}
label={category.category}
checked={user.drivers_licence.licence_categories != null &&
user.drivers_licence.licence_categories.some(
(cat) => cat.category === category.category
)}
/>
</div>
<span class="checkbox-description">
{$t(`licenceCategory.${category.category}`)}
</span>
</div>
{/each}
</div>
</div>
</div>
<div
class="tab-content"
style="display: {activeTab === 'membership' ? 'block' : 'none'}"
>
<InputField
name="membership_status"
type="select"
label={$t("status")}
bind:value={user.membership.status}
options={membershipStatusOptions}
/>
<InputField
name="subscription_model_name"
type="select"
label={$t("subscription_model")}
bind:value={user.membership.subscription_model.name}
options={subscriptionModelOptions}
/>
<div class="subscription-info">
<div class="subscription-column">
<p>
<strong>{$t("monthly_fee")}:</strong>
{selectedSubscriptionModel?.monthly_fee || "-"}
</p>
<p>
<strong>{$t("hourly_rate")}:</strong>
{selectedSubscriptionModel?.hourly_rate || "-"}
</p>
{#if selectedSubscriptionModel?.included_hours_per_year}
<p>
<strong>{$t("included_hours_per_year")}:</strong>
{selectedSubscriptionModel?.included_hours_per_year}
</p>
{/if}
{#if selectedSubscriptionModel?.included_hours_per_month}
<p>
<strong>{$t("included_hours_per_month")}:</strong>
{selectedSubscriptionModel?.included_hours_per_month}
</p>
{/if}
</div>
<div class="subscription-column">
<p>
<strong>{$t("details")}:</strong>
{selectedSubscriptionModel?.details || "-"}
</p>
{#if selectedSubscriptionModel?.conditions}
<p>
<strong>{$t("conditions")}:</strong>
{selectedSubscriptionModel?.conditions}
</p>
{/if}
</div>
</div>
<InputField
name="membership_start_date"
type="date"
label={$t("start")}
bind:value={user.membership.start_date}
placeholder={$t("placeholder.start_date")}
/>
<InputField
name="membership_end_date"
type="date"
label={$t("end")}
bind:value={user.membership.end_date}
placeholder={$t("placeholder.end_date")}
/>
<InputField
name="parent_member_id"
type="number"
label={$t("parent_member_id")}
bind:value={user.membership.parent_member_id}
placeholder={$t("placeholder.parent_member_id")}
/>
</div>
<div
class="tab-content"
style="display: {activeTab === 'bankaccount' ? 'block' : 'none'}"
>
<InputField
name="account_holder_name"
label={$t("bank_account_holder")}
bind:value={user.bank_account.account_holder_name}
placeholder={$t("placeholder.bank_account_holder")}
/>
<InputField
name="bank"
label={$t("bank_name")}
bind:value={user.bank_account.bank}
placeholder={$t("placeholder.bank_name")}
/>
<InputField
name="iban"
label={$t("iban")}
bind:value={user.bank_account.iban}
placeholder={$t("placeholder.iban")}
toUpperCase={true}
/>
<InputField
name="bic"
label={$t("bic")}
bind:value={user.bank_account.bic}
placeholder={$t("placeholder.bic")}
toUpperCase={true}
/>
<InputField
name="mandate_reference"
label={$t("mandate_reference")}
bind:value={user.bank_account.mandate_reference}
placeholder={$t("placeholder.mandate_reference")}
/>
</div>
<div class="button-container">
{#if isUpdating}
<SmallLoader width={30} message={"Aktualisiere..."} />
{:else}
<button type="button" class="button-dark" on:click={close}>
{$t("cancel")}</button
>
<button type="submit" class="button-dark">{$t("confirm")}</button>
{/if}
</div>
</form>
</Modal>
<Modal on:close={close}>
<UserEditForm
{form}
{user}
{subscriptions}
{licence_categories}
on:close={close}
on:cancel={close}
editor={user}
/>
</Modal>
{/if}
<style>
.licence-categories {
margin-bottom: 20px;
}
.hero-container .hero-subtitle:not(:last-of-type) {
margin: 0 0 0 0;
}
.checkbox-grid {
display: grid;
grid-template-columns: 1fr;
gap: 5px;
}
.hero-container {
display: flex;
flex-direction: column;
align-items: center;
}
.checkbox-item {
display: flex;
justify-content: space-between;
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);
}
.checkbox-label-container {
flex: 0 0 auto;
margin-right: 10px;
}
.info-row {
display: contents;
}
.checkbox-description {
flex: 1;
font-size: 14px;
color: #9b9b9b;
text-align: right;
margin-left: 10px;
}
.label {
font-size: 1.3rem;
font-weight: bold;
text-align: left;
padding-right: 1rem;
color: var(--lavender);
}
@media (min-width: 768px) {
.checkbox-grid {
grid-template-columns: 1fr 1fr;
gap: 20px;
}
}
.value {
margin: 0;
font-size: 1.2rem;
text-align: left;
color: var(--text);
}
@media (max-width: 480px) {
.checkbox-item {
flex-direction: column;
align-items: flex-start;
}
.block-value {
display: flex;
align-items: flex-start;
flex-direction: column;
color: var(--subtext0);
}
.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;
}
.hero-buttons-container {
margin-top: 1rem;
}
.subscription-column {
flex: 1;
min-width: 200px;
}
.user-info {
font-size: 1rem;
}
.subscription-column p {
margin: 0.5rem 0;
}
.subscription-column strong {
display: inline-block;
min-width: 100px;
}
.tab-content {
padding: 1rem;
border-radius: 0 0 3px 3px;
}
.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;
}
.info-row {
display: contents;
}
.label {
font-size: 1.3rem;
font-weight: bold;
text-align: left;
padding-right: 1rem;
}
.value {
margin: 0;
font-size: 1.2rem;
text-align: left;
}
.block-value {
display: flex;
align-items: flex-start;
flex-direction: column;
}
.hero-buttons-container {
margin-top: 1rem;
}
.user-info {
font-size: 1rem;
}
.label,
.value {
font-size: 1.1rem;
}
/* .avatar-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.avatar-form {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
.current-avatar {
margin-bottom: 1rem;
}
.avatar-buttons {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
width: 100%;
max-width: 200px;
}
.avatar-buttons button {
margin-top: 1.5rem;
width: 100%;
}
.avatar-selection {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
margin-top: 1rem;
}
.avatar-option {
width: 80px;
height: 80px;
padding: 0;
border: none;
background: none;
cursor: pointer;
transition: transform 0.2s;
}
.avatar-option:hover,
.avatar-option:focus {
transform: scale(1.8);
}
.avatar-option img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
} */
.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);
}
@media (max-width: 480px) {
.button-container button {
flex-basis: 100%;
max-width: none;
}
}
.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

@@ -1,92 +1,77 @@
import { BASE_API_URI } from "$lib/utils/constants";
import { formatError } from "$lib/utils/helpers";
import { fail, redirect } from "@sveltejs/kit";
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
if (locals.user) {
throw redirect(302, "/");
}
// 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 }) => {
const data = await request.formData();
const email = String(data.get("email"));
const password = String(data.get("password"));
const next = String(data.get("next"));
/**
*
* @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));
/** @type {RequestInit} */
const requestInitOptions = {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: email,
password: password,
}),
};
if (!res.ok) {
const errorData = await res.json();
const errors = formatError(errorData.errors);
return fail(res.status, { errors });
}
const res = await fetch(`${BASE_API_URI}/users/login/`, requestInitOptions);
const responseBody = await res.json();
console.log('Login response body:', responseBody);
console.log("Login response status:", res.status);
console.log("Login response headers:", Object.fromEntries(res.headers));
// 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
});
}
}
if (!res.ok) {
let errorMessage = `HTTP error! status: ${res.status}`;
try {
const errorData = await res.json();
errorMessage = errorData.error || errorMessage;
} catch (parseError) {
console.error("Failed to parse error response:", parseError);
errorMessage = await res.text(); // Get the raw response text if JSON parsing fails
}
console.error("Login failed:", errorMessage);
return fail(res.status, {
errors: [{ error: errorMessage, id: Date.now() }],
});
}
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 || "/");
throw redirect(303, next || "/");
},
// if (!res.ok) {
// const response = await res.json();
// const errors = formatError(response.error);
// return fail(400, { errors: errors });
// }
// throw redirect(303, next || "/");
// },
console.log('Redirecting to:', next || `${base}/auth/about/${responseBody.user_id}`);
throw redirect(303, next || `${base}/auth/about/${responseBody.user_id}`);
}
};

View File

@@ -1,94 +1,81 @@
<script>
import { applyAction, enhance } from "$app/forms";
import { page } from "$app/stores";
import { receive, send } from "$lib/utils/helpers";
import { t } from "svelte-i18n";
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').ActionData} */
export let form;
/** @type {import('./$types').SubmitFunction} */
const handleLogin = async () => {
return async ({ result }) => {
await applyAction(result);
};
};
/** @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", " ");
}
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={{ key: error.id }}
out:send={{ key: error.id }}
>
{error.error}
</h4>
{/each}
{/if}
<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">{message}</h4>
{/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("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="/auth/password/request-change" class="forgot-password"
>{$t("forgot_password")}?</a
>
</div>
</div>
<div class="btn-container">
<button class="button-dark">{$t("login")} </button>
</div>
</form>
<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;
}
.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;
}
.forgot-password {
margin-top: 0.5rem;
font-size: 0.9em;
}
</style>

View File

@@ -1,57 +1,55 @@
import { BASE_API_URI } from "$lib/utils/constants";
import { fail, redirect } from "@sveltejs/kit";
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, `/auth/login?next=/`);
}
// 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")}`,
},
};
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}/backend/users/logout/`,
requestInitOptions
);
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 });
}
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: "/" });
// 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, "/auth/login");
},
// 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>

View File

@@ -1,15 +1,17 @@
// import adapter from '@sveltejs/adapter-auto';
import adapter from '@sveltejs/adapter-vercel';
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://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter({
runtime: 'edge'
})
// 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' : ''
}
}
};

View File

@@ -1,8 +1,9 @@
import { sveltekit } from '@sveltejs/kit/vite';
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()
err := database.Open(config.DB.Path, config.Recipients.AdminEmail)
db, err := database.Open(config.DB.Path, config.Recipients.AdminEmail, config.Env == "development")
if err != nil {
logger.Error.Fatalf("Couldn't init database: %v", err)
}
defer func() {
if err := database.Close(); err != nil {
if err := database.Close(db); err != nil {
logger.Error.Fatalf("Failed to close database: %v", err)
}
}()
go server.Run()
go server.Run(db)
gracefulShutdown()
}

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

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

View File

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

View File

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

View File

@@ -12,16 +12,21 @@ require (
)
require (
github.com/alexedwards/argon2id v1.0.0
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/mocktools/go-smtp-mock/v2 v2.3.1
github.com/stretchr/testify v1.9.0
github.com/wagslane/go-password-validator v0.3.0
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
)
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/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
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/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
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/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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/go.mod h1:ctv/bD2EGRR5PobFrJSXZ/FZXCFtUbmVv6v2qf/b/88=
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/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
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.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
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/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/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.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/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/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
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/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/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=

View File

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

@@ -26,6 +26,6 @@ func testXSSAttempt(t *testing.T) {
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, http.StatusNotAcceptable, w.Code)
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

@@ -17,11 +17,13 @@ import (
"github.com/gin-gonic/gin"
"GoMembership/internal/config"
"GoMembership/internal/constants"
"GoMembership/internal/database"
"GoMembership/internal/models"
"GoMembership/internal/repositories"
"GoMembership/internal/services"
"GoMembership/internal/utils"
"GoMembership/internal/validation"
"GoMembership/pkg/logger"
)
@@ -43,12 +45,14 @@ type loginInput struct {
}
var (
Uc *UserController
Mc *MembershipController
Cc *ContactController
Uc *UserController
Mc *MembershipController
Cc *ContactController
AdminCookie *http.Cookie
MemberCookie *http.Cookie
)
func TestSuite(t *testing.T) {
func TestMain(t *testing.T) {
_ = deleteTestDB("test.db")
cwd, err := os.Getwd()
@@ -83,7 +87,8 @@ func TestSuite(t *testing.T) {
log.Fatalf("Error setting environment variable: %v", err)
}
config.LoadConfig()
if err := database.Open("test.db", config.Recipients.AdminEmail); err != nil {
db, err := database.Open("test.db", config.Recipients.AdminEmail, true)
if err != nil {
log.Fatalf("Failed to create DB: %#v", err)
}
utils.SMTPStart(Host, Port)
@@ -95,25 +100,55 @@ func TestSuite(t *testing.T) {
bankAccountService := &services.BankAccountService{Repo: bankAccountRepo}
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}
var licenceRepo repositories.DriversLicenceInterface = &repositories.DriversLicenceRepository{}
var userRepo repositories.UserRepositoryInterface = &repositories.UserRepository{}
userService := &services.UserService{Repo: userRepo, Licences: licenceRepo}
var licenceRepo repositories.LicenceInterface = &repositories.LicenceRepository{}
userService := &services.UserService{DB: db, Licences: licenceRepo}
Uc = &UserController{Service: userService, EmailService: emailService, ConsentService: consentService, BankAccountService: bankAccountService, MembershipService: membershipService}
Mc = &MembershipController{Service: *membershipService}
licenceService := &services.LicenceService{Repo: licenceRepo}
Uc = &UserController{Service: userService, LicenceService: licenceService, EmailService: emailService, ConsentService: consentService, BankAccountService: bankAccountService, MembershipService: membershipService}
Mc = &MembershipController{UserService: userService, Service: membershipService}
Cc = &ContactController{EmailService: emailService}
if err := initSubscriptionPlans(); err != nil {
log.Fatalf("Failed to init Subscription plans: %#v", err)
}
if err := initLicenceCategories(); err != nil {
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) {
testUserController(t)
})
t.Run("Password_Controller", func(t *testing.T) {
})
t.Run("SQL_Injection", func(t *testing.T) {
testSQLInjectionAttempt(t)
})
@@ -129,7 +164,6 @@ func TestSuite(t *testing.T) {
t.Run("XSSAttempt", func(t *testing.T) {
testXSSAttempt(t)
})
if err := utils.SMTPStop(); err != nil {
log.Fatalf("Failed to stop SMTP Mockup Server: %#v", err)
}
@@ -139,8 +173,37 @@ func TestSuite(t *testing.T) {
// }
}
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 {
subscriptions := []models.SubscriptionModel{
subscriptions := []models.Subscription{
{
Name: "Basic",
Details: "Test Plan",
@@ -212,23 +275,41 @@ func GetMockedFormContext(formData url.Values, url string) (*gin.Context, *httpt
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{SubscriptionModel: models.SubscriptionModel{Name: "Basic"}},
DriversLicence: models.DriversLicence{},
ProfilePicture: "",
Password: "password123",
Company: "",
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 {
err := os.Remove(dbPath)
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})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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