Compare commits

..

182 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
176 changed files with 16099 additions and 4576 deletions

6
.gitignore vendored
View File

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

View File

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

37
config.json.template Normal file
View File

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

2
frontend/.env.template Normal file
View File

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

39
frontend/.gitignore vendored Normal file
View File

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

1
frontend/.npmrc Normal file
View File

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

4
frontend/.prettierignore Normal file
View File

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

15
frontend/.prettierrc Normal file
View File

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

14
frontend/Dockerfile Normal file
View File

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

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: 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 # or start the server and open the app in a new browser tab
npm run dev -- --open 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'
});

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

@@ -2,69 +2,109 @@
interface Subscription { interface Subscription {
id: number | -1; id: number | -1;
name: string | ""; name: string | '';
details?: string | ""; details: string | '';
conditions?: string | ""; conditions: string | '';
monthly_fee?: number | -1; monthly_fee: number | 0;
hourly_rate?: number | -1; hourly_rate: number | 0;
included_hours_per_year?: number | 0; included_hours_per_year: number | 0;
included_hours_per_month?: number | 0; included_hours_per_month: number | 0;
} }
interface Membership { interface Membership {
id: number | -1; id: number | -1;
status: number | -1; status: number | -1;
start_date: string | ""; start_date: string | '';
end_date: string | ""; end_date: string | '';
parent_member_id: number | -1; parent_member_id: number | -1;
subscription_model: Subscription; subscription: Subscription;
} }
interface BankAccount { interface BankAccount {
id: number | -1; id: number | -1;
mandate_date_signed: string | ""; mandate_date_signed: string | '';
bank: string | ""; bank: string | '';
account_holder_name: string | ""; account_holder_name: string | '';
iban: string | ""; iban: string | '';
bic: string | ""; bic: string | '';
mandate_reference: string | ""; mandate_reference: string | '';
} }
interface Licence { interface Licence {
id: number | -1; id: number | -1;
status: number | -1; status: number | -1;
licence_number: string | ""; number: string | '';
issued_date: string | ""; issued_date: string | '';
expiration_date: string | ""; expiration_date: string | '';
country: string | ""; country: string | '';
licence_categories: LicenceCategory[]; categories: LicenceCategory[];
} }
interface LicenceCategory { interface LicenceCategory {
id: number | -1; id: number | -1;
category: string | ""; category: string | '';
} }
interface User { interface User {
email: string | ""; email: string | '';
first_name: string | ""; first_name: string | '';
last_name: string | ""; last_name: string | '';
phone: string | ""; password: string | '';
notes: string | ""; phone: string | '';
address: string | ""; address: string | '';
zip_code: string | ""; zip_code: string | '';
city: string | ""; city: string | '';
status: number | -1; status: number | -1;
id: number | -1; id: number | -1;
role_id: number | -1; role_id: number | -1;
date_of_birth: string | ""; dateofbirth: string | '';
company: string | ""; company: string | '';
profile_picture: string | ""; membership: Membership | null;
payment_status: number | -1; bank_account: BankAccount | null;
membership: Membership; licence: Licence | null;
bank_account: BankAccount; notes: string | '';
licence: Licence; }
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 { declare global {
@@ -73,9 +113,22 @@ declare global {
interface Locals { interface Locals {
user: User; user: User;
users: User[]; users: User[];
cars: Cars[];
subscriptions: Subscription[]; subscriptions: Subscription[];
licence_categories: LicenceCategory[]; licence_categories: LicenceCategory[];
} }
interface Types {
licenceCategory: LicenceCategory;
subscription: Subscription;
membership: Membership;
licence: Licence;
licenceCategory: LicenceCategory;
bankAccount: BankAccount;
car: Car;
insurance: Insurance;
location: Location;
damage: Damage;
}
// interface PageData {} // interface PageData {}
// interface Platform {} // interface Platform {}
} }

View File

@@ -1,15 +1,23 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="de">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <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.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=Poiret+One&family=Quicksand:wght@400;500;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=Poiret+One&family=Quicksand:wght@400;500;600;700&display=swap"
rel="stylesheet" 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% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">

View File

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

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

View File

@@ -1,20 +1,66 @@
<script> <script>
import { onMount } from "svelte"; import { onMount, onDestroy } from 'svelte';
import { applyAction, enhance } from "$app/forms"; import { applyAction, enhance } from '$app/forms';
import { page } from "$app/stores"; import { base } from '$app/paths';
// import Developer from "$lib/img/hero-image.png"; import { page } from '$app/stores';
import Avatar from "$lib/img/TeamAvatar.jpeg"; import { t } from 'svelte-i18n';
import { writable } from 'svelte/store';
import { PERMISSIONS } from '$lib/utils/constants';
import { hasPrivilige } from '$lib/utils/helpers';
let isMobileMenuOpen = false;
/** @type{HTMLDivElement} */
let headerContainer;
onMount(() => { onMount(() => {
console.log("Page data in Header:", $page); 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); 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> </script>
<header class="header"> <header class="header">
<div class="header-container"> <div class="header-container" bind:this={headerContainer}>
<div class="header-left"> <div class="header-left">
<div class="header-crafted-by-container"> <div class="header-crafted-by-container">
<!-- <a href="https://tiny-bits.net/"> <!-- <a href="https://tiny-bits.net/">
@@ -25,16 +71,23 @@
<!-- </a> --> <!-- </a> -->
</div> </div>
</div> </div>
<div class="header-right"> <div class="mobile-menu-container">
<div class="header-nav-item" class:active={$page.url.pathname === "/"}> <button
<a href="/">home</a> 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> </div>
{#if !$page.data.user} {#if !$page.data.user}
<div <div class="header-nav-item" class:active={$page.url.pathname === `${base}/auth/login`}>
class="header-nav-item" <a href={`${base}/auth/login`}>login</a>
class:active={$page.url.pathname === "/auth/login"}
>
<a href="/auth/login">login</a>
</div> </div>
<!-- <div <!-- <div
class="header-nav-item" class="header-nav-item"
@@ -44,15 +97,23 @@
</div> --> </div> -->
{:else} {:else}
<div class="header-nav-item"> <div class="header-nav-item">
<a href="/auth/about/{$page.data.user.id}"> <a href={`${base}/auth/about/${$page.data.user.id}`}>
<img <!-- <img
src={$page.data.user.profile_picture src={$page.data.user.profile_picture ? $page.data.user.profile_picture : Avatar}
? $page.data.user.profile_picture
: Avatar}
alt={`${$page.data.user.first_name} ${$page.data.user.last_name}`} alt={`${$page.data.user.first_name} ${$page.data.user.last_name}`}
/> /> -->
{$page.data.user.first_name}
{$page.data.user.last_name}
</a> </a>
</div> </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} <!-- {#if $page.data.user.is_superuser}
<div <div
class="header-nav-item" class="header-nav-item"
@@ -63,7 +124,7 @@
{/if} --> {/if} -->
<form <form
class="header-nav-item" class="header-nav-item"
action="/auth/logout" action={`${base}/auth/logout`}
method="POST" method="POST"
use:enhance={async () => { use:enhance={async () => {
return async ({ result }) => { return async ({ result }) => {
@@ -74,6 +135,264 @@
<button type="submit">logout</button> <button type="submit">logout</button>
</form> </form>
{/if} {/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>
</div> </div>
</header> </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,18 +1,17 @@
<script> <script>
import { createEventDispatcher } from "svelte"; import { t } from 'svelte-i18n';
import { t } from "svelte-i18n";
/** @type {string} */ /** @type {string} */
export let name; export let name;
/** @type {string} */ /** @type {string} */
export let type = "text"; export let type = 'text';
/** @type {string|Number|null} */ /** @type {string|Number|null} */
export let value; export let value;
/** @type {string} */ /** @type {string} */
export let placeholder = ""; export let placeholder = '';
/** @type {Number} */ /** @type {Number} */
export let rows = 4; export let rows = 4;
@@ -24,10 +23,10 @@
export let required = false; export let required = false;
/** @type {string} */ /** @type {string} */
export let label = ""; export let label = '';
/** @type {string} */ /** @type {string} */
export let otherPasswordValue = ""; export let otherPasswordValue = '';
/** @type {boolean} */ /** @type {boolean} */
export let toUpperCase = false; export let toUpperCase = false;
@@ -38,6 +37,9 @@
/** @type {boolean} */ /** @type {boolean} */
export let readonly = false; export let readonly = false;
/** @type {string} */
export let backgroundColor = '--surface0';
/** /**
* @param {Event} event - The input event * @param {Event} event - The input event
*/ */
@@ -48,9 +50,9 @@
let inputValue = target.value; let inputValue = target.value;
if (toUpperCase) { if (toUpperCase) {
inputValue = inputValue.toUpperCase(); inputValue = inputValue.toUpperCase();
target.value = inputValue; // Update the input field value
} }
value = inputValue; target.value = inputValue; // Update the input field value
value = inputValue.trim();
} }
} }
@@ -62,66 +64,54 @@
* @returns {string|null} The error message or null if valid * @returns {string|null} The error message or null if valid
*/ */
function validateField(name, value, required) { function validateField(name, value, required) {
if ( if (value === null || (typeof value === 'string' && !value.trim() && !required)) return null;
value === null || if (name.includes('membership_start_date')) {
(typeof value === "string" && !value.trim() && !required) return typeof value === 'string' && value.trim() ? null : $t('validation.date');
) } else if (name.includes('email')) {
return null; return typeof value === 'string' && /^\S+@\S+\.\S+$/.test(value)
switch (name) {
case "membership_start_date":
return typeof value === "string" && value.trim()
? null ? null
: $t("validation.date"); : $t('validation.email');
case "email": } else if (name.includes('password')) {
return typeof value === "string" && /^\S+@\S+\.\S+$/.test(value) if (typeof value === 'string' && value.length < 8) {
? null return $t('validation.password');
: $t("validation.email");
case "password":
case "password2":
if (typeof value === "string" && value.length < 8) {
return $t("validation.password");
} }
if (otherPasswordValue && value !== otherPasswordValue) { if (otherPasswordValue && value !== otherPasswordValue) {
return $t("validation.password_match"); return $t('validation.password_match');
} }
return null; return null;
case "phone": } else if (name.includes('phone')) {
return typeof value === "string" && /^\+?[0-9\s()-]{7,}$/.test(value) return typeof value === 'string' && /^\+?[0-9\s()-]{7,}$/.test(value)
? null ? null
: $t("validation.phone"); : $t('validation.phone');
case "zip_code": } else if (name.includes('zip_code')) {
return typeof value === "string" && /^\d{5}$/.test(value) return typeof value === 'string' && /^\d{5}$/.test(value) ? null : $t('validation.zip_code');
} else if (name.includes('iban')) {
return typeof value === 'string' && /^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(value)
? null ? null
: $t("validation.zip_code"); : $t('validation.iban');
case "iban": } else if (name.includes('bic')) {
return typeof value === "string" && return typeof value === 'string' && /^[A-Z]{6}[A-Z2-9][A-NP-Z0-9]([A-Z0-9]{3})?$/.test(value)
/^[A-Z]{2}\d{2}[A-Z0-9]{1,30}$/.test(value)
? null ? null
: $t("validation.iban"); : $t('validation.bic');
case "bic": } else if (name.includes('licence_number')) {
return typeof value === "string" && return typeof value === 'string' && value.length == 11 ? null : $t('validation.licence');
/^[A-Z]{6}[A-Z2-9][A-NP-Z0-9]([A-Z0-9]{3})?$/.test(value) } else {
? null return typeof value === 'string' && !value.trim() && required
: $t("validation.bic"); ? $t('validation.required')
case "licence_number":
return typeof value === "string" && value.length == 11
? null
: $t("validation.licence");
default:
return typeof value === "string" && !value.trim() && required
? $t("validation.required")
: null; : null;
} }
} }
$: error = validateField(name, value, required); $: error = validateField(name, value, required);
$: selectedOption = options.find((option) => option.value == value); $: selectedOption = options.find((option) => option.value == value);
$: selectedColor = selectedOption ? selectedOption.color : ""; $: selectedColor = selectedOption ? `var(${selectedOption.color})` : '';
</script> </script>
<div class="input-box {type === 'checkbox' ? 'checkbox-container' : ''}"> <div
{#if type === "checkbox"} class="input-box {type === 'checkbox' ? 'checkbox-container' : ''}"
style="background-color: var({backgroundColor});"
>
{#if type === 'checkbox'}
<label class="form-control {readonly ? 'form-control--disabled' : ''}"> <label class="form-control {readonly ? 'form-control--disabled' : ''}">
<input <input
type="checkbox" type="checkbox"
@@ -140,19 +130,26 @@
{#if error} {#if error}
<span class="error-message">{error}</span> <span class="error-message">{error}</span>
{/if} {/if}
{#if type === "select"} {#if readonly}
<input {name} type="hidden" bind:value />
<span class="label"
>{type == 'select' && typeof value === 'number' ? options[value].label : value}</span
>
{:else if type === 'select'}
<select <select
{name} {name}
bind:value bind:value
{required} {required}
class="input select" class="input select"
style={selectedColor ? `color: ${selectedColor};` : ""} style={selectedColor ? `color: ${selectedColor};` : ''}
disabled={readonly}
> >
{#each options as option} {#each options as option}
<option value={option.value}>{option.label}</option> <option value={option.value}>{option.label}</option>
{/each} {/each}
</select> </select>
{:else if type === "textarea"} {:else if type === 'textarea'}
<textarea <textarea
{name} {name}
{placeholder} {placeholder}
@@ -162,8 +159,8 @@
{rows} {rows}
class="input textarea {readonly ? 'readonly' : ''}" class="input textarea {readonly ? 'readonly' : ''}"
style="height:{rows * 1.5}em;" style="height:{rows * 1.5}em;"
/> ></textarea>
{:else if type != "checkbox"} {:else if type != 'checkbox'}
<input <input
{name} {name}
{type} {type}
@@ -181,8 +178,8 @@
<style> <style>
:root { :root {
--form-control-color: #6bff55; --form-control-color: var(--green); /* Changed from #6bff55 */
--form-control-disabled: #959495; --form-control-disabled: var(--subtext1); /* Changed from #959495 */
} }
.form-control { .form-control {
@@ -194,6 +191,7 @@
gap: 0.75em; gap: 0.75em;
align-items: center; align-items: center;
opacity: 0.8; opacity: 0.8;
color: var(--text);
} }
.form-control--disabled { .form-control--disabled {
@@ -201,16 +199,16 @@
cursor: not-allowed; cursor: not-allowed;
} }
input[type="checkbox"] { input[type='checkbox'] {
-webkit-appearance: none; -webkit-appearance: none;
appearance: none; appearance: none;
background-color: var(--form-background); background-color: var(--surface0);
margin: 0; margin: 0;
font: inherit; font: inherit;
color: currentColor; color: var(--text);
width: 1.75em; width: 1.75em;
height: 1.75em; height: 1.75em;
border: 0.15em solid currentColor; border: 0.15em solid var(--overlay0);
border-radius: 0.5em; border-radius: 0.5em;
transform: translateY(-0.075em); transform: translateY(-0.075em);
display: grid; display: grid;
@@ -218,8 +216,8 @@
transition: transform 0.2s ease-in-out; transition: transform 0.2s ease-in-out;
} }
input[type="checkbox"]::before { input[type='checkbox']::before {
content: ""; content: '';
width: 1em; width: 1em;
height: 1em; height: 1em;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%); clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
@@ -227,39 +225,41 @@
transform-origin: bottom left; transform-origin: bottom left;
transition: 120ms transform ease-in-out; transition: 120ms transform ease-in-out;
box-shadow: inset 1em 1em var(--form-control-color); box-shadow: inset 1em 1em var(--form-control-color);
background-color: CanvasText; background-color: var(--crust);
} }
input[type="checkbox"]:checked::before { input[type='checkbox']:checked::before {
transform: scale(1); transform: scale(1);
} }
input[type="checkbox"]:hover { input[type='checkbox']:hover {
outline: max(2px, 0.15em) solid currentColor; outline: max(2px, 0.15em) solid var(--lavender);
outline-offset: max(2px, 0.15em); outline-offset: max(2px, 0.15em);
transform: scale(1.3); transform: scale(1.3);
} }
input[type="checkbox"]:disabled { input[type='checkbox']:disabled {
--form-control-color: var(--form-control-disabled); --form-control-color: var(--form-control-disabled);
color: var(--form-control-disabled); color: var(--form-control-disabled);
cursor: not-allowed; cursor: not-allowed;
} }
.readonly { .readonly {
background-color: #ececec; background-color: var(--surface0);
cursor: not-allowed; cursor: not-allowed;
opacity: 0.7; opacity: 0.7;
color: #4f4f4f; color: var(--overlay1);
} }
.checkbox-container { .checkbox-container {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
background-color: transparent; background-color: transparent;
margin: 0.5rem 0;
} }
.checkbox-text { .checkbox-text {
font-size: 16px; font-size: 16px;
color: var(--text);
} }
@media (min-width: 768px) { @media (min-width: 768px) {
@@ -270,16 +270,19 @@
} }
.select { .select {
padding-right: 1.5em; padding-right: 1.5em;
background-color: var(--surface0);
font-weight: bold;
} }
.input-error-container { .input-error-container {
display: flex; display: flex;
align-items: flex-end; flex-direction: column;
gap: 0.5rem;
width: 100%; width: 100%;
max-width: 444px; max-width: 444px;
} }
.error-message { .error-message {
color: #eb5424; color: var(--red); /* Changed from #eb5424 */
font-size: 12px; font-size: 12px;
margin-bottom: 5px; margin-bottom: 5px;
align-self: flex-start; align-self: flex-start;
@@ -292,13 +295,49 @@
textarea, textarea,
select { select {
width: 100%; width: 100%;
padding: 0.5rem; padding: 0.75rem 0;
border: 1px solid #ccc; background-color: var(--surface0);
border-radius: 4px; border: 1px solid var(--overlay0);
color: white; 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 { textarea {
resize: vertical; resize: vertical;
min-height: 100px; 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> </style>

View File

@@ -1,8 +1,5 @@
<script> <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 modal = (/** @type {Element} */ node, { duration = 300 } = {}) => {
const transform = getComputedStyle(node).transform; const transform = getComputedStyle(node).transform;
@@ -16,45 +13,13 @@
scale(${t}) scale(${t})
translateY(${u * -100}%) translateY(${u * -100}%)
`; `;
},
};
};
const dispatch = createEventDispatcher();
function closeModal() {
dispatch("close", {});
} }
};
};
</script> </script>
<div class="modal-background"> <div class="modal-background">
<div <div transition:modal|global={{ duration: 1000 }} class="modal" role="dialog" aria-modal="true">
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"> <div class="container">
<slot /> <slot />
</div> </div>
@@ -69,7 +34,8 @@
top: 0; top: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.9); background: var(--modal-backdrop); /* var(--base) with 0.75 opacity */
backdrop-filter: blur(4px); /* Optional: adds a slight blur effect */
z-index: 9999; z-index: 9999;
display: flex; display: flex;
} }
@@ -79,50 +45,49 @@
left: 50%; left: 50%;
top: 50%; top: 50%;
width: 70%; width: 70%;
box-shadow: 0 0 10px hsl(0 0% 0% / 10%); 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%); 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) { @media (max-width: 990px) {
.modal { .modal {
width: 90%; width: 90%;
} }
} }
.modal-close {
border: none;
}
.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 { .modal .container {
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
align-items: center; align-items: center;
padding: 2rem;
background-color: var(--base);
border-radius: 8px;
} }
/* Scrollbar styling */
.modal .container::-webkit-scrollbar {
width: 8px;
}
.modal .container::-webkit-scrollbar-track {
background: var(--surface0);
border-radius: 4px;
}
.modal .container::-webkit-scrollbar-thumb {
background: var(--surface2);
border-radius: 4px;
}
.modal .container::-webkit-scrollbar-thumb:hover {
background: var(--surface1);
}
@media (min-width: 680px) { @media (min-width: 680px) {
.modal .container { .modal .container {
flex-direction: column; flex-direction: column;
left: 0; left: 0;
width: 100%;
} }
} }
</style> </style>

View File

@@ -6,7 +6,7 @@
</script> </script>
<div class="loading"> <div class="loading">
<p class="simple-loader" style={width ? `width: ${width}px` : ""} /> <p class="simple-loader" style={width ? `width: ${width}px` : ''}></p>
{#if message} {#if message}
<p>{message}</p> <p>{message}</p>
{/if} {/if}

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

View File

@@ -1,66 +1,95 @@
<script> <script>
import InputField from "$lib/components/InputField.svelte"; import InputField from '$lib/components/InputField.svelte';
import SmallLoader from "$lib/components/SmallLoader.svelte"; import SmallLoader from '$lib/components/SmallLoader.svelte';
import { applyAction, enhance } from "$app/forms"; import { createEventDispatcher } from 'svelte';
import { receive, send } from "$lib/utils/helpers"; import { applyAction, enhance } from '$app/forms';
import { t } from "svelte-i18n"; 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} */ /** @type {import('../../routes/auth/about/[id]/$types').ActionData} */
export let form; export let form;
/** @type {App.Locals['subscriptions']}*/ /** @type {App.Locals['subscriptions'] | null}*/
export let subscriptions; export let subscriptions;
/** @type {App.Locals['user']}*/ /** @type {App.Locals['user']} */
export let user; export let user;
/** @type {App.Locals['licence_categories']} */ 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; export let licence_categories;
const userStatusOptions = [ const userStatusOptions = [
{ value: 1, label: $t("userStatus.1"), color: "#b1b1b1" }, // Grey for "Nicht verifiziert" { value: 1, label: $t('userStatus.1'), color: '--subtext1' }, // Grey for "Nicht verifiziert"
{ value: 2, label: $t("userStatus.2"), color: "#90EE90" }, // Light green for "Verifiziert" { value: 2, label: $t('userStatus.2'), color: '--light-green' }, // Light green for "Verifiziert"
{ value: 3, label: $t("userStatus.3"), color: "#00bc00" }, // Green for "Aktiv" { value: 3, label: $t('userStatus.3'), color: '--green' }, // Green for "Aktiv"
{ value: 4, label: $t("userStatus.4"), color: "#FFC0CB" }, // Pink for "Passiv" { value: 4, label: $t('userStatus.4'), color: '--pink' }, // Pink for "Passiv"
{ value: 5, label: $t("userStatus.5"), color: "#FF4646" }, // Red for "Deaktiviert" { value: 5, label: $t('userStatus.5'), color: '--red' } // Red for "Deaktiviert"
]; ];
const userRoleOptions = [ const userRoleOptions = [
{ value: 0, label: $t("userRole.0"), color: "#b1b1b1" }, // Grey for "Mitglied" { value: -1, label: $t('userRole.-1'), color: '--red' }, // Red for "Opponent"
{ value: 1, label: $t("userRole.1"), color: "#00bc00" }, // Green for "Betrachter" { value: 0, label: $t('userRole.0'), color: '--subtext1' }, // Grey for "Nicht verifiziert"
{ value: 4, label: $t("userRole.4"), color: "#FFC0CB" }, // Pink for "Bearbeiter" { value: 1, label: $t('userRole.1'), color: '--light-green' }, // Light green for "Verifiziert"
{ value: 8, label: $t("userRole.8"), color: "#FF4646" }, // Red for "Admin" { 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 = [ const membershipStatusOptions = [
{ value: 3, label: $t("userStatus.3"), color: "#00bc00" }, // Green for "Aktiv" { value: 3, label: $t('userStatus.3'), color: '--green' }, // Green for "Aktiv"
{ value: 4, label: $t("userStatus.4"), color: "#FFC0CB" }, // Pink for "Passiv" { value: 4, label: $t('userStatus.4'), color: '--pink' }, // Pink for "Passiv"
{ value: 5, label: $t("userStatus.5"), color: "#FF4646" }, // Red for "Deaktiviert" { value: 5, label: $t('userStatus.5'), color: '--red' } // Red for "Deaktiviert"
]; ];
const licenceStatusOptions = [ const licenceStatusOptions = [
{ value: 1, label: $t("userStatus.1"), color: "#b1b1b1" }, // Grey for "Nicht verifiziert" { value: 1, label: $t('userStatus.1'), color: '--subtext1' }, // Grey for "Nicht verifiziert"
{ value: 3, label: $t("userStatus.3"), color: "#00bc00" }, // Green for "Aktiv" { value: 3, label: $t('userStatus.3'), color: '--green' }, // Green for "Aktiv"
{ value: 4, label: $t("userStatus.4"), color: "#FFC0CB" }, // Pink for "Passiv" { value: 4, label: $t('userStatus.4'), color: '--pink' }, // Pink for "Passiv"
{ value: 5, label: $t("userStatus.5"), color: "#FF4646" }, // Red for "Deaktiviert" { value: 5, label: $t('userStatus.5'), color: '--red' } // Red for "Deaktiviert"
]; ];
const TABS = ["profile", "licence", "membership", "bankaccount"]; const dispatch = createEventDispatcher();
let activeTab = TABS[0]; /** @type { (keyof user)[] } */
const TABS = ['membership', 'licence', 'bank_account'];
let activeTab = 'profile';
let isUpdating = false, let isUpdating = false,
password = "", password = '',
password2 = ""; confirm_password = '';
/** @type {Object.<string, App.Locals['licence_categories']>} */ /** @type {Object.<string, App.Locals['licence_categories']>} */
$: groupedCategories = groupCategories(licence_categories); $: groupedCategories = licence_categories ? groupCategories(licence_categories) : {};
$: subscriptionModelOptions = subscriptions.map((sub) => ({ $: subscriptionOptions = subscriptions
value: sub?.name ?? "", ? subscriptions.map((sub) => ({
label: sub?.name ?? "", value: sub?.name ?? '',
})); label: sub?.name ?? ''
$: selectedSubscriptionModel = }))
subscriptions.find( : [];
(sub) => sub?.id === user.membership?.subscription_model.id $: selectedSubscription = subscriptions
) || null; ? subscriptions.find((sub) => sub?.name === user.membership?.subscription.name) || null
: null;
/** /**
* creates groups of categories depending on the first letter * creates groups of categories depending on the first letter
* @param {App.Locals['licence_categories']} categories - the categories to sort and group * @param {App.Locals['licence_categories']} categories - the categories to sort and group
@@ -70,10 +99,7 @@
return Object.entries(categories) return Object.entries(categories)
.sort((a, b) => a[1].category.localeCompare(b[1].category)) .sort((a, b) => a[1].category.localeCompare(b[1].category))
.reduce( .reduce(
( (/** @type {Object.<string, App.Locals['licence_categories']>} */ acc, [, category]) => {
/** @type {Object.<string, App.Locals['licence_categories']>} */ acc,
[_, category]
) => {
const firstLetter = category.category[0]; const firstLetter = category.category[0];
if (!acc[firstLetter]) { if (!acc[firstLetter]) {
acc[firstLetter] = []; acc[firstLetter] = [];
@@ -85,241 +111,266 @@
); );
} }
/** /** @type {import('@sveltejs/kit').SubmitFunction} */
* Sets the active tab const handleUpdate = ({ cancel }) => {
* @param {string} tab - The tab to set as active if (!submit_form) {
*/ cancel();
function setActiveTab(tab) { dispatch('close');
activeTab = tab; return;
} }
/** @type {import('../../routes/auth/about/[id]/$types').SubmitFunction} */
const handleUpdate = async ({ form, formData, action, cancel }) => {
isUpdating = true; isUpdating = true;
return async ({ result }) => { return async ({ result }) => {
isUpdating = false; isUpdating = false;
if (result.type === "success" || result.type === "redirect") {
close(); if (result.type === 'success' || result.type === 'redirect') {
dispatch('close');
} else {
document.querySelector('.modal .container')?.scrollTo({ top: 0, behavior: 'smooth' });
} }
await applyAction(result); console.log('submitting');
return submit_form ? await applyAction(result) : undefined;
}; };
}; };
</script> </script>
<form {#if isLoading}
<SmallLoader width={30} message={$t('loading.user_data')} />
{:else if user}
<form
class="content" class="content"
action="?/updateUser" action="?/updateUser"
method="POST" method="POST"
use:enhance={handleUpdate} use:enhance={handleUpdate}
> on:submit={(/** @type{SubmitEvent}*/ e) => {
<input name="id" type="number" hidden bind:value={user.id} /> if (!submit_form) {
<h1 class="step-title" style="text-align: center;">{$t("user_edit")}</h1> 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} {#if form?.success}
<h4 <h4
class="step-subtitle warning" class="step-subtitle warning"
in:receive={{ key: Math.floor(Math.random() * 100) }} in:receive|global={{ key: Math.floor(Math.random() * 100) }}
out:send={{ 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 Um einen fehlerhaften upload Ihres Bildes zu vermeiden, clicke bitte auf den "Update" Button
den "Update" Button unten. unten.
</h4> </h4>
{/if} {/if}
{#if form?.errors} {#if form?.errors}
{#each form?.errors as error (error.id)} {#each form?.errors as error (error.id)}
<h4 <h4
class="step-subtitle warning" class="step-subtitle warning"
in:receive={{ key: error.id }} in:receive|global={{ key: error.id }}
out:send={{ key: error.id }} out:send|global={{ key: error.id }}
> >
{$t(error.field) + ": " + $t(error.key)} {$t(error.field) + ': ' + $t(error.key)}
</h4> </h4>
{/each} {/each}
{/if} {/if}
<input
type="hidden"
hidden
name="profile_picture"
bind:value={user.profile_picture}
/>
<div class="button-container"> <div class="button-container">
<button
type="button"
class="button-dark"
class:active={activeTab === 'profile'}
on:click={() => (activeTab = 'profile')}
>
{$t('profile')}
</button>
{#each TABS as tab} {#each TABS as tab}
{#if user[tab] != null}
<button <button
type="button" type="button"
class="button-dark" class="button-dark"
class:active={activeTab === tab} class:active={activeTab === tab}
on:click={() => setActiveTab(tab)} on:click={() => (activeTab = tab)}
> >
{$t(tab)} {$t('user.' + tab)}
</button> </button>
{/if}
{/each} {/each}
</div> </div>
<div <div class="tab-content" style="display: {activeTab === 'profile' ? 'block' : 'none'}">
class="tab-content" {#if hasPrivilige(user, PERMISSIONS.Member)}
style="display: {activeTab === 'profile' ? 'block' : 'none'}"
>
<InputField <InputField
name="status" name="user[status]"
type="select" type="select"
label={$t("status")} label={$t('status')}
bind:value={user.status} bind:value={user.status}
options={userStatusOptions} options={userStatusOptions}
readonly={readonlyUser}
/> />
{#if user.role_id === 8} {/if}
{#if hasPrivilige(editor, PERMISSIONS.Super)}
<InputField <InputField
name="role_id" name="user[role_id]"
type="select" type="select"
label={$t("user_role")} label={$t('user.role')}
bind:value={user.role_id} bind:value={user.role_id}
options={userRoleOptions} options={userRoleOptions}
/> />
{/if} {/if}
{#if hasPrivilige(user, PERMISSIONS.Member)}
<InputField <InputField
name="password" name="user[password]"
type="password" type="password"
label={$t("password")} label={$t('password')}
placeholder={$t("placeholder.password")} placeholder={$t('placeholder.password')}
bind:value={password} bind:value={password}
otherPasswordValue={password2} otherPasswordValue={confirm_password}
/> />
<InputField <InputField
name="password2" name="confirm_password"
type="password" type="password"
label={$t("password_repeat")} label={$t('confirm_password')}
placeholder={$t("placeholder.password")} placeholder={$t('placeholder.password')}
bind:value={password2} bind:value={confirm_password}
otherPasswordValue={password} otherPasswordValue={password}
/> />
{/if}
<InputField <InputField
name="first_name" name="user[first_name]"
label={$t("first_name")} label={$t('user.first_name')}
bind:value={user.first_name} bind:value={user.first_name}
placeholder={$t("placeholder.first_name")} placeholder={$t('placeholder.first_name')}
required={true} required={true}
readonly={readonlyUser}
/> />
<InputField <InputField
name="last_name" name="user[last_name]"
label={$t("last_name")} label={$t('user.last_name')}
bind:value={user.last_name} bind:value={user.last_name}
placeholder={$t("placeholder.last_name")} placeholder={$t('placeholder.last_name')}
required={true} required={true}
readonly={readonlyUser}
/> />
<InputField <InputField
name="company" name="user[company]"
label={$t("company")} label={$t('company')}
bind:value={user.company} bind:value={user.company}
placeholder={$t("placeholder.company")} placeholder={$t('placeholder.company')}
/> />
<InputField <InputField
name="email" name="user[email]"
type="email" type="email"
label={$t("email")} label={$t('user.email')}
bind:value={user.email} bind:value={user.email}
placeholder={$t("placeholder.email")} placeholder={$t('placeholder.email')}
required={true} required={true}
/> />
<InputField <InputField
name="phone" name="user[phone]"
type="tel" type="tel"
label={$t("phone")} label={$t('user.phone')}
bind:value={user.phone} bind:value={user.phone}
placeholder={$t("placeholder.phone")} placeholder={$t('placeholder.phone')}
/> />
<InputField <InputField
name="birth_date" name="user[dateofbirth]"
type="date" type="date"
label={$t("birth_date")} label={$t('user.dateofbirth')}
bind:value={user.date_of_birth} bind:value={user.dateofbirth}
placeholder={$t("placeholder.birth_date")} placeholder={$t('placeholder.dateofbirth')}
readonly={readonlyUser}
/> />
<InputField <InputField
name="address" name="user[address]"
label={$t("address")} label={$t('address')}
bind:value={user.address} bind:value={user.address}
placeholder={$t("placeholder.address")} placeholder={$t('placeholder.address')}
/> />
<InputField <InputField
name="zip_code" name="user[zip_code]"
label={$t("zip_code")} label={$t('zip_code')}
bind:value={user.zip_code} bind:value={user.zip_code}
placeholder={$t("placeholder.zip_code")} placeholder={$t('placeholder.zip_code')}
/> />
<InputField <InputField
name="city" name="user[city]"
label={$t("city")} label={$t('city')}
bind:value={user.city} bind:value={user.city}
placeholder={$t("placeholder.city")} placeholder={$t('placeholder.city')}
/> />
{#if !readonlyUser}
<InputField <InputField
name="notes" name="user[notes]"
type="textarea" type="textarea"
label={$t("notes")} label={$t('notes')}
bind:value={user.notes} bind:value={user.notes}
placeholder={$t("placeholder.notes", { placeholder={$t('placeholder.notes', {
values: { name: user.first_name || "" }, values: { name: user.first_name || '' }
})} })}
rows={10} rows={10}
/> />
{/if}
</div> </div>
<div
class="tab-content" {#if hasPrivilige(user, PERMISSIONS.Member) && user.licence}
style="display: {activeTab === 'licence' ? 'block' : 'none'}" <div class="tab-content" style="display: {activeTab === 'licence' ? 'block' : 'none'}">
>
<InputField <InputField
name="licence_status" name="user[licence][status]"
type="select" type="select"
label={$t("status")} label={$t('status')}
bind:value={user.licence.status} bind:value={user.licence.status}
options={licenceStatusOptions} options={licenceStatusOptions}
readonly={readonlyUser}
/> />
<InputField <InputField
name="licence_number" name="user[licence][number]"
type="text" type="text"
label={$t("licence_number")} label={$t('licence_number')}
bind:value={user.licence.licence_number} bind:value={user.licence.number}
placeholder={$t("placeholder.licence_number")} placeholder={$t('placeholder.licence_number')}
toUpperCase={true} toUpperCase={true}
readonly={readonlyUser}
/> />
<InputField <InputField
name="issued_date" name="user[licence][issued_date]"
type="date" type="date"
label={$t("issued_date")} label={$t('issued_date')}
bind:value={user.licence.issued_date} bind:value={user.licence.issued_date}
placeholder={$t("placeholder.issued_date")} placeholder={$t('placeholder.issued_date')}
readonly={readonlyUser}
/> />
<InputField <InputField
name="expiration_date" name="user[licence][expiration_date]"
type="date" type="date"
label={$t("expiration_date")} label={$t('expiration_date')}
bind:value={user.licence.expiration_date} bind:value={user.licence.expiration_date}
placeholder={$t("placeholder.expiration_date")} placeholder={$t('placeholder.expiration_date')}
readonly={readonlyUser}
/> />
<InputField <InputField
name="country" name="user[licence][country]"
label={$t("country")} label={$t('country')}
bind:value={user.licence.country} bind:value={user.licence.country}
placeholder={$t("placeholder.issuing_country")} placeholder={$t('placeholder.issuing_country')}
readonly={readonlyUser}
/> />
<div class="licence-categories"> <div class="licence-categories">
<h3>{$t("licence_categories")}</h3> <h3>{$t('licence_categories')}</h3>
<div class="checkbox-grid"> <div class="checkbox-grid">
{#each Object.entries(groupedCategories) as [group, categories], groupIndex} {#each Object.entries(groupedCategories) as [, categories], groupIndex}
{#if groupIndex > 0} {#if groupIndex > 0}
<div class="category-break" /> <div class="category-break"></div>
{/if} {/if}
{#each categories as category} {#each categories as category}
<div class="checkbox-item"> <div class="checkbox-item">
<div class="checkbox-label-container"> <div class="checkbox-label-container">
<InputField <InputField
type="checkbox" type="checkbox"
name="licence_categories[]" name="user[licence][categories][]"
value={JSON.stringify(category)} value={JSON.stringify(category)}
label={category.category} label={category.category}
checked={user.licence.licence_categories != null && checked={user.licence.categories != null &&
user.licence.licence_categories.some( user.licence.categories.some((cat) => cat.category === category.category)}
(cat) => cat.category === category.category
)}
/> />
</div> </div>
<span class="checkbox-description"> <span class="checkbox-description">
@@ -331,143 +382,156 @@
</div> </div>
</div> </div>
</div> </div>
{/if}
{#if user.membership}
<div <div
class="tab-content" class="tab-content"
style="display: {activeTab === 'membership' ? 'block' : 'none'}" style="display: {activeTab === 'membership' && subscriptions ? 'block' : 'none'}"
> >
<InputField <InputField
name="membership_status" name="user[membership][status]"
type="select" type="select"
label={$t("status")} label={$t('status')}
bind:value={user.membership.status} bind:value={user.membership.status}
options={membershipStatusOptions} options={membershipStatusOptions}
readonly={readonlyUser}
/> />
<InputField <InputField
name="subscription_model_name" name="user[membership][subscription][name]"
type="select" type="select"
label={$t("subscription_model")} label={$t('subscriptions.subscription')}
bind:value={user.membership.subscription_model.name} bind:value={user.membership.subscription.name}
options={subscriptionModelOptions} options={subscriptionOptions}
readonly={readonlyUser || !hasPrivilige(user, PERMISSIONS.Member)}
/> />
<div class="subscription-info"> <div class="subscription-info">
{#if hasPrivilige(user, PERMISSIONS.Member)}
<div class="subscription-column"> <div class="subscription-column">
<p> <p>
<strong>{$t("monthly_fee")}:</strong> <strong>{$t('subscriptions.monthly_fee')}:</strong>
{selectedSubscriptionModel?.monthly_fee || "-"} {selectedSubscription?.monthly_fee || '-'}
</p> </p>
<p> <p>
<strong>{$t("hourly_rate")}:</strong> <strong>{$t('subscriptions.hourly_rate')}:</strong>
{selectedSubscriptionModel?.hourly_rate || "-"} {selectedSubscription?.hourly_rate || '-'}
</p> </p>
{#if selectedSubscriptionModel?.included_hours_per_year} {#if selectedSubscription?.included_hours_per_year}
<p> <p>
<strong>{$t("included_hours_per_year")}:</strong> <strong>{$t('subscriptions.included_hours_per_year')}:</strong>
{selectedSubscriptionModel?.included_hours_per_year} {selectedSubscription?.included_hours_per_year}
</p> </p>
{/if} {/if}
{#if selectedSubscriptionModel?.included_hours_per_month} {#if selectedSubscription?.included_hours_per_month}
<p> <p>
<strong>{$t("included_hours_per_month")}:</strong> <strong>{$t('subscriptions.included_hours_per_month')}:</strong>
{selectedSubscriptionModel?.included_hours_per_month} {selectedSubscription?.included_hours_per_month}
</p> </p>
{/if} {/if}
</div> </div>
{/if}
<div class="subscription-column"> <div class="subscription-column">
<p> <p>
<strong>{$t("details")}:</strong> <strong>{$t('details')}:</strong>
{selectedSubscriptionModel?.details || "-"} {selectedSubscription?.details || '-'}
</p> </p>
{#if selectedSubscriptionModel?.conditions} {#if selectedSubscription?.conditions}
<p> <p>
<strong>{$t("conditions")}:</strong> <strong>{$t('subscriptions.conditions')}:</strong>
{selectedSubscriptionModel?.conditions} {selectedSubscription?.conditions}
</p> </p>
{/if} {/if}
</div> </div>
</div> </div>
<InputField <InputField
name="membership_start_date" name="user[membership][start_date]"
type="date" type="date"
label={$t("start")} label={$t('start')}
bind:value={user.membership.start_date} bind:value={user.membership.start_date}
placeholder={$t("placeholder.start_date")} placeholder={$t('placeholder.start_date')}
readonly={readonlyUser}
/> />
<InputField <InputField
name="membership_end_date" name="user[membership][end_date]"
type="date" type="date"
label={$t("end")} label={$t('end')}
bind:value={user.membership.end_date} bind:value={user.membership.end_date}
placeholder={$t("placeholder.end_date")} placeholder={$t('placeholder.end_date')}
readonly={readonlyUser}
/> />
{#if hasPrivilige(user, PERMISSIONS.Member)}
<InputField <InputField
name="parent_member_id" name="user[membership][parent_member_id]"
type="number" type="number"
label={$t("parent_member_id")} label={$t('parent_member_id')}
bind:value={user.membership.parent_member_id} bind:value={user.membership.parent_member_id}
placeholder={$t("placeholder.parent_member_id")} placeholder={$t('placeholder.parent_member_id')}
readonly={readonlyUser}
/> />
{/if}
</div> </div>
<div {/if}
class="tab-content" {#if user.bank_account}
style="display: {activeTab === 'bankaccount' ? 'block' : 'none'}" <div class="tab-content" style="display: {activeTab === 'bank_account' ? 'block' : 'none'}">
>
<InputField <InputField
name="account_holder_name" name="user[bank_account][account_holder_name]"
label={$t("bank_account_holder")} label={$t('bank_account_holder')}
bind:value={user.bank_account.account_holder_name} bind:value={user.bank_account.account_holder_name}
placeholder={$t("placeholder.bank_account_holder")} placeholder={$t('placeholder.bank_account_holder')}
/> />
<InputField <InputField
name="bank" name="user[bank_account][bank_name]"
label={$t("bank_name")} label={$t('bank_name')}
bind:value={user.bank_account.bank} bind:value={user.bank_account.bank}
placeholder={$t("placeholder.bank_name")} placeholder={$t('placeholder.bank_name')}
/> />
<InputField <InputField
name="iban" name="user[bank_account][iban]"
label={$t("iban")} label={$t('iban')}
bind:value={user.bank_account.iban} bind:value={user.bank_account.iban}
placeholder={$t("placeholder.iban")} placeholder={$t('placeholder.iban')}
toUpperCase={true} toUpperCase={true}
/> />
<InputField <InputField
name="bic" name="user[bank_account][bic]"
label={$t("bic")} label={$t('bic')}
bind:value={user.bank_account.bic} bind:value={user.bank_account.bic}
placeholder={$t("placeholder.bic")} placeholder={$t('placeholder.bic')}
toUpperCase={true} toUpperCase={true}
/> />
<InputField <InputField
name="mandate_reference" name="user[bank_account][mandate_reference]"
label={$t("mandate_reference")} label={$t('mandate_reference')}
bind:value={user.bank_account.mandate_reference} bind:value={user.bank_account.mandate_reference}
placeholder={$t("placeholder.mandate_reference")} placeholder={$t('placeholder.mandate_reference')}
readonly={readonlyUser}
/> />
<InputField <InputField
name="mandate_date_signed" name="user[bank_account][mandate_date_signed]"
label={$t("mandate_date_signed")} label={$t('mandate_date_signed')}
type="date" type="date"
bind:value={user.bank_account.mandate_date_signed} bind:value={user.bank_account.mandate_date_signed}
readonly={true} readonly={true}
/> />
</div> </div>
{/if}
<div class="button-container"> <div class="button-container">
{#if isUpdating} {#if isUpdating}
<SmallLoader width={30} message={"Aktualisiere..."} /> <SmallLoader width={30} message={$t('loading.updating')} />
{:else} {:else}
<button type="button" class="button-dark" on:click={close}> <button type="button" class="button-dark" on:click={() => dispatch('cancel')}>
{$t("cancel")}</button {$t('cancel')}</button
> >
<button type="submit" class="button-dark">{$t("confirm")}</button> <button type="submit" class="button-dark">{$t('confirm')}</button>
{/if} {/if}
</div> </div>
</form> </form>
{/if}
<style> <style>
.category-break { .category-break {
grid-column: 1 / -1; grid-column: 1 / -1;
height: 1px; height: 1px;
background-color: #ccc; background-color: var(--overlay0);
margin-top: 10px; margin-top: 10px;
margin-left: 20%; margin-left: 20%;
width: 60%; width: 60%;
@@ -499,7 +563,7 @@
.checkbox-description { .checkbox-description {
flex: 1; flex: 1;
font-size: 15px; font-size: 15px;
color: #9b9b9b; color: var(--subtext0);
margin-left: 10px; margin-left: 10px;
} }
@@ -528,11 +592,16 @@
gap: 1rem; gap: 1rem;
margin-top: 1rem; margin-top: 1rem;
font-size: 0.9rem; font-size: 0.9rem;
background-color: var(--surface0);
padding: 1rem;
border-radius: 8px;
border: 1px solid var(--surface1);
} }
.subscription-column { .subscription-column {
flex: 1; flex: 1;
min-width: 200px; min-width: 200px;
color: var(--text);
} }
.subscription-column p { .subscription-column p {
@@ -542,10 +611,14 @@
.subscription-column strong { .subscription-column strong {
display: inline-block; display: inline-block;
min-width: 100px; min-width: 100px;
color: var(--lavender);
} }
.tab-content { .tab-content {
padding: 1rem; padding: 1rem;
border-radius: 0 0 3px 3px; border-radius: 0 0 3px 3px;
background-color: var(--surface0);
border: 1px solid var(--surface1);
margin-top: 1rem;
} }
.button-container { .button-container {
display: flex; display: flex;
@@ -559,8 +632,24 @@
.button-container button { .button-container button {
flex: 1 1 0; flex: 1 1 0;
min-width: 120px; min-width: 120px;
max-width: calc(50%-5px); 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) { @media (max-width: 480px) {
.button-container button { .button-container button {
flex-basis: 100%; flex-basis: 100%;

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,23 +1,121 @@
: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-face {
font-family: "Roboto Mono"; font-family: 'Roboto Mono';
font-style: normal; font-style: normal;
font-weight: 300; font-weight: 300;
src: url(https://fonts.gstatic.com/s/robotomono/v22/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_gPq_ROW9.ttf) src: url(https://fonts.gstatic.com/s/robotomono/v22/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_gPq_ROW9.ttf)
format("truetype"); format('truetype');
} }
@font-face { @font-face {
font-family: "Roboto Mono"; font-family: 'Roboto Mono';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
src: url(https://fonts.gstatic.com/s/robotomono/v22/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vq_ROW9.ttf) src: url(https://fonts.gstatic.com/s/robotomono/v22/L0xuDF4xlVMF-BfR8bXMIhJHg45mwgGEFl0_3vq_ROW9.ttf)
format("truetype"); format('truetype');
} }
html { html {
padding: 0 30px; padding: 0 30px;
background-color: black; background-color: var(--base);
color: #9b9b9b; color: var(--text);
font-family: "Quicksand", sans-serif; font-family: 'Quicksand', sans-serif;
font-size: 16px; font-size: 16px;
font-weight: normal; font-weight: normal;
} }
@@ -28,13 +126,13 @@ body {
pre, pre,
code { code {
display: inline; display: inline;
font-family: "Roboto Mono", monospace; font-family: 'Roboto Mono', monospace;
font-size: 16px; font-size: 16px;
} }
input { input {
font-family: "Roboto Mono", monospace; font-family: 'Roboto Mono', monospace;
color: white; color: var(--text);
border-style: none; border-style: none;
height: 21px; height: 21px;
font-size: 16px; font-size: 16px;
@@ -53,12 +151,12 @@ h6 {
} }
h2 { h2 {
margin: 0 0 45px 0; margin: 0 0 45px 0;
color: #fff; color: var(--lavender);
font-size: 36px; font-size: 36px;
} }
h3 { h3 {
margin: 0 0 2rem 0; margin: 0 0 2rem 0;
color: #fff; color: var(--lavender);
font-size: 32px; font-size: 32px;
} }
p { p {
@@ -72,16 +170,16 @@ a {
transition: border 0.2s ease-in-out; transition: border 0.2s ease-in-out;
border-bottom: 1px solid transparent; border-bottom: 1px solid transparent;
text-decoration: none; text-decoration: none;
color: #00b7ef; color: var(--blue);
} }
a:hover { a:hover {
border-bottom-color: #00b7ef; border-bottom-color: var(--blue);
} }
li { li {
line-height: 1.8; line-height: 1.8;
} }
li strong { li strong {
color: #fff; color: var(--text);
} }
.image { .image {
width: 100%; width: 100%;
@@ -109,163 +207,69 @@ li strong {
} }
} }
.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 { .button-dark {
transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out; transition:
color: white; border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
color: var(--white);
text-transform: uppercase; text-transform: uppercase;
font-weight: 500; font-weight: 500;
padding: 18px 28px; padding: 18px 28px;
letter-spacing: 1px; letter-spacing: 1px;
cursor: pointer; cursor: pointer;
background-color: transparent; background-color: transparent;
border: 1px solid #595b5c; border: 1px solid var(--surface1);
margin: 2px; margin: 2px;
} }
.button-dark:hover { .button-dark:hover {
border-color: #fff; border-color: var(--text);
} }
.button-colorful { .button-colorful {
transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out; transition:
color: white; border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
color: var(--white);
text-transform: uppercase; text-transform: uppercase;
font-weight: 500; font-weight: 500;
padding: 18px 28px; padding: 18px 28px;
letter-spacing: 1px; letter-spacing: 1px;
cursor: pointer; cursor: pointer;
background-color: #d43aff; background-color: var(--mauve);
border: 1px solid #d43aff; border: 1px solid var(--mauve);
} }
.button-colorful:hover { .button-colorful:hover {
background-color: #c907ff; background-color: var(--bright-mauve);
border-color: #c907ff; border-color: var(--bright-mauve);
} }
.button-orange { .button-orange {
transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out; transition:
color: white; border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
color: var(--white);
text-transform: uppercase; text-transform: uppercase;
font-weight: 500; font-weight: 500;
padding: 18px 28px; padding: 18px 28px;
letter-spacing: 1px; letter-spacing: 1px;
cursor: pointer; cursor: pointer;
background-color: #eb5424; background-color: var(--peach);
border: 1px solid #eb5424; border: 1px solid var(--peach);
} }
.button-orange:hover { .button-orange:hover {
background-color: #ca3f12; background-color: var(--bright-peach);
border-color: #ca3f12; border-color: var(--bright-peach);
} }
.button-colorful:disabled { .button-colorful:disabled {
transition: border-color 0.3s ease-in-out, background-color 0.3s ease-in-out; transition:
color: white; border-color 0.3s ease-in-out,
background-color 0.3s ease-in-out;
color: var(--white);
text-transform: uppercase; text-transform: uppercase;
font-weight: 500; font-weight: 500;
padding: 18px 28px; padding: 18px 28px;
letter-spacing: 1px; letter-spacing: 1px;
cursor: pointer; cursor: pointer;
background-color: #9a9a9a; background-color: var(--overlay0);
border: 1px solid #9a9a9a; border: 1px solid var(--overlay0);
} }
.hero-container { .hero-container {
max-width: 795px; max-width: 795px;
@@ -298,7 +302,7 @@ li strong {
.container { .container {
transition: opacity 0.2s ease-in-out; transition: opacity 0.2s ease-in-out;
color: white; color: var(--white);
letter-spacing: 0; letter-spacing: 0;
opacity: 1; opacity: 1;
} }
@@ -308,7 +312,7 @@ li strong {
flex-grow: 1; flex-grow: 1;
} }
.container .content .step-title { .container .content .step-title {
color: #fff; color: var(--white);
font-size: 20px; font-size: 20px;
font-weight: 500; font-weight: 500;
line-height: 86px; line-height: 86px;
@@ -346,14 +350,12 @@ li strong {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin: 10px 0;
padding: 10px;
width: 100%; width: 100%;
height: auto; height: auto;
box-sizing: border-box; box-sizing: border-box;
background-color: #2f2f2f; background-color: var(--surface0);
border-radius: 3px; border-radius: 3px;
font-family: "Roboto Mono", monospace; font-family: 'Roboto Mono', monospace;
font-size: 13px; font-size: 13px;
} }
.input-box .label { .input-box .label {
@@ -361,10 +363,10 @@ li strong {
font-size: 16px; font-size: 16px;
} }
.input-box .input { .input-box .input {
background-color: #494848; background-color: var(--surface1);
border: 3px solid var(--surface1);
border-radius: 6px; border-radius: 6px;
outline: none; outline: none;
border: 3px solid #494848;
width: 100%; width: 100%;
max-width: 444px; max-width: 444px;
font-size: 13px; font-size: 13px;
@@ -387,19 +389,61 @@ li strong {
margin-left: 1rem; 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 { .warning {
margin: 20px 0; margin: 20px 0;
padding: 1rem; padding: 1rem;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
background-color: rgb(255 228 230); background-color: var(--surface0);
border: 1px solid rgb(225 29 72); border: 1px solid var(--red);
color: var(--red);
border-radius: 6px; border-radius: 6px;
color: rgb(225 29 72);
font-size: 16px; font-size: 16px;
} }
.warning a { .warning a {
color: rgb(225 29 72); color: var(--red);
text-decoration: underline; text-decoration: underline;
} }
.warning.hidden { .warning.hidden {
@@ -409,8 +453,8 @@ li strong {
.error { .error {
margin-top: 10rem; margin-top: 10rem;
padding: 30px 40px; padding: 30px 40px;
background: #2f3132; background: var(--surface0);
color: #fff; color: var(--text);
} }
.error p { .error p {
margin: 0 0 1rem; margin: 0 0 1rem;
@@ -428,7 +472,7 @@ li strong {
} }
.footer-branding-container { .footer-branding-container {
color: white; color: var(--white);
font-weight: 300; font-weight: 300;
margin-bottom: 73px; margin-bottom: 73px;
} }
@@ -447,7 +491,7 @@ li strong {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 16px; margin-bottom: 16px;
color: white; color: var(--white);
} }
.footer-branding-container .footer-branding .footer-crafted-by-container span { .footer-branding-container .footer-branding .footer-crafted-by-container span {
display: inline-block; display: inline-block;
@@ -461,12 +505,12 @@ li strong {
} }
.footer-branding-container .footer-branding .footer-copyright { .footer-branding-container .footer-branding .footer-copyright {
color: #696969; color: var(--overlay0);
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
.footer-container { .footer-container {
width: 100%; width: 100%;
color: white; color: var(--white);
box-sizing: border-box; box-sizing: border-box;
display: flex; display: flex;
justify-content: center; justify-content: center;
@@ -483,7 +527,7 @@ li strong {
--b: 20px; /* border thickness */ --b: 20px; /* border thickness */
--n: 15; /* number of dashes*/ --n: 15; /* number of dashes*/
--g: 7deg; /* gap between dashes*/ --g: 7deg; /* gap between dashes*/
--c: #d43aff; /* the color */ --c: var(--mauve); /* Changed loader color to match theme */
width: 40px; /* size */ width: 40px; /* size */
aspect-ratio: 1; aspect-ratio: 1;
@@ -495,11 +539,7 @@ li strong {
#000 1deg calc(360deg / var(--n) - var(--g) - 1deg), #000 1deg calc(360deg / var(--n) - var(--g) - 1deg),
#0000 calc(360deg / var(--n) - var(--g)) calc(360deg / var(--n)) #0000 calc(360deg / var(--n) - var(--g)) calc(360deg / var(--n))
), ),
radial-gradient( radial-gradient(farthest-side, #0000 calc(98% - var(--b)), var(--black) calc(100% - var(--b)));
farthest-side,
#0000 calc(98% - var(--b)),
#000 calc(100% - var(--b))
);
-webkit-mask: var(--_m); -webkit-mask: var(--_m);
mask: var(--_m); mask: var(--_m);
-webkit-mask-composite: destination-in; -webkit-mask-composite: destination-in;

View File

@@ -1,146 +1,264 @@
export default { export default {
userStatus: { userStatus: {
1: "Nicht verifiziert", 1: 'Nicht verifiziert',
2: "Verifiziert", 2: 'Deaktiviert',
3: "Aktiv", 3: 'Verifiziert',
4: "Passiv", 4: 'Systemzugang',
5: "Deaktiviert", 5: 'Passiv'
}, },
userRole: { userRole: {
0: "Mitglied", '-1': 'Unfallgegner',
1: "Betrachter", 0: 'Sponsor',
4: "Bearbeiter", 1: 'Mitglied',
8: "Administrator", 2: 'Betrachter',
4: 'Bearbeiter',
8: 'Administrator'
}, },
placeholder: { placeholder: {
password: "Passwort eingeben...", car_name: 'Hat das Fahrzeug einen Namen?',
email: "Emailadresse eingeben...", car_brand: 'Fahrzeughersteller eingeben...',
company: "Firmennamen eingeben...", car_model: 'Fahrzeugmodell eingeben...',
first_name: "Vornamen eingeben...", car_color: 'Fahrzeugfarbe eingeben...',
last_name: "Nachnamen eingeben...", car_licence_plate: 'Fahrzeugkennzeichen eingeben...',
phone: "Telefonnummer eingeben...", insurance_reference: 'Versicherungsnummer eingeben...',
address: "Straße und Hausnummer eingeben...", password: 'Passwort eingeben...',
zip_code: "Postleitzahl eingeben...", email: 'Emailadresse eingeben...',
city: "Wohnort eingeben...", company: 'Firmennamen eingeben...',
bank_name: "Namen der Bank eingeben...", first_name: 'Vornamen eingeben...',
parent_member_id: "Mitgliedsnr des Hauptmitglieds eingeben...", last_name: 'Nachnamen eingeben...',
bank_account_holder: "Namen eingeben...", phone: 'Telefonnummer eingeben...',
iban: "IBAN eingeben..", address: 'Straße und Hausnummer eingeben...',
bic: "BIC eingeben(Bei nicht deutschen Konten)...", zip_code: 'Postleitzahl eingeben...',
mandate_reference: "SEPA Mandatsreferenz eingeben..", city: 'Wohnort eingeben...',
notes: "Deine Notizen zu {name}...", bank_name: 'Namen der Bank eingeben...',
licence_number: "Auf dem Führerschein unter Feld 5", parent_member_id: 'Mitgliedsnr des Hauptmitglieds eingeben...',
issued_date: "Ausgabedatum unter Feld 4a", bank_account_holder: 'Namen eingeben...',
expiration_date: "Ablaufdatum unter Feld 4b", iban: 'IBAN eingeben..',
issuing_country: "Ausstellendes Land", 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: { validation: {
required: "Eingabe benötigt", required: 'Eingabe benötigt',
password: "Password zu kurz, mindestens 8 Zeichen", password: 'Password zu kurz, mindestens 8 Zeichen',
password_match: "Passwörter stimmen nicht überein!", password_match: 'Passwörter stimmen nicht überein!',
phone: "Ungültiges Format(+491738762387 oder 0173850698)", phone: 'Ungültiges Format(+491738762387 oder 0173850698)',
zip_code: "Ungültige Postleitzahl(Nur deutsche Wohnorte sind zulässig)", zip_code: 'Ungültige Postleitzahl(Nur deutsche Wohnorte sind zulässig)',
bic: "Ungültige BIC", bic: 'Ungültige BIC',
iban: "Ungültige IBAN", iban: 'Ungültige IBAN',
date: "Bitte geben Sie ein Datum ein", date: 'Bitte geben Sie ein Datum ein',
email: "Ungültige Emailadresse", email: 'Ungültige Emailadresse',
licence: "Nummer zu kurz(11 Zeichen)", licence: 'Nummer zu kurz(11 Zeichen)'
}, },
server: { server: {
general: 'Allgemein',
error: { error: {
invalid_json: "JSON Daten sind ungültig", invalid_json: 'JSON Daten sind ungültig',
no_auth_token: "Nicht authorisiert, fehlender oder ungültiger Auth-Token", no_auth_token: 'Nicht authorisiert, fehlender oder ungültiger Auth-Token',
jwt_parsing_error: jwt_parsing_error: 'Nicht authorisiert, Auth-Token konnte nicht gelesen werden',
"Nicht authorisiert, Auth-Token konnte nicht gelesen werden", unauthorized: 'Sie sind nicht befugt diese Handlung durchzuführen',
unauthorized_update: "Sie sind nicht befugt dieses Update durchzuführen",
internal_server_error: internal_server_error:
"Verdammt, fehler auf unserer Seite, probieren Sie es nochmal, danach rufen Sie nach Hilfe", '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: { validation: {
no_user_id_provided: "Nutzer ID fehlt im Header", invalid: 'ungültig',
invalid_subscription_model: "Model nicht gefunden", invalid_user_id: 'Nutzer ID ungültig',
user_not_found: "{field} konnte nicht gefunden werden", invalid_subscription: 'Model nicht gefunden',
invalid_user_data: "Nutzerdaten ungültig", user_not_found: '{field} konnte nicht gefunden werden',
user_not_found_or_wrong_password: invalid_user_data: 'Nutzerdaten ungültig',
"Existiert nicht oder falsches Passwort", user_not_found_or_wrong_password: 'Existiert nicht oder falsches Passwort',
email_already_registered: email_already_registered: 'Ein Mitglied wurde schon mit dieser Emailadresse erstellt.',
"Ein Mitglied wurde schon mit dieser Emailadresse erstellt.", password_already_changed: 'Das Passwort wurde schon geändert.',
alphanumunicode: "beinhaltet nicht erlaubte Zeichen", user_already_verified: 'Ihre Email Adresse wurde schon bestätigt.',
safe_content: "I see what you did there! Do not cross this line!", insecure: 'Unsicheres Passwort, versuchen Sie {message}',
iban: "Ungültig. Format: DE07123412341234123412", longer: 'oder verwenden Sie ein längeres Passwort',
bic: "Ungültig. Format: BELADEBEXXX", special: 'mehr Sonderzeichen einzufügen',
email: "Format ungültig", lowercase: 'Kleinbuchstaben zu verwenden',
number: "Ist keine Nummer", uppercase: 'Großbuchstaben zu verwenden',
euDriversLicence: "Ist kein europäischer Führerschein", numbers: 'Zahlen zu verwenden',
lte: "Ist zu groß/neu", alphanumunicode: 'beinhaltet nicht erlaubte Zeichen',
gt: "Ist zu klein/alt", safe_content: 'I see what you did there! Do not cross this line!',
required: "Feld wird benötigt", iban: 'Ungültig. Format: DE07123412341234123412',
image: "Dies ist kein Bild", bic: 'Ungültig. Format: BELADEBEXXX',
alphanum: "beinhaltet ungültige Zeichen", email: 'Format ungültig',
alphaunicode: "darf nur aus Buchstaben bestehen", 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: { licenceCategory: {
AM: "Mopeds und leichte vierrädrige Kraftfahrzeuge (50ccm, max 45km/h)", AM: 'Mopeds und leichte vierrädrige Kraftfahrzeuge (50ccm, max 45km/h)',
A1: "Leichte Motorräder (125ccm)", A1: 'Leichte Motorräder (125ccm)',
A2: "Motorräder mit mittlerer Leistung (max 35kW)", A2: 'Motorräder mit mittlerer Leistung (max 35kW)',
A: "Motorräder", A: 'Motorräder',
B: "Kraftfahrzeuge ≤ 3500 kg, ≤ 8 Sitzplätze", B: 'Kraftfahrzeuge ≤ 3500 kg, ≤ 8 Sitzplätze',
C1: "Mittelschwere Fahrzeuge -7500 kg", C1: 'Mittelschwere Fahrzeuge -7500 kg',
C: "Schwere Nutzfahrzeuge > 3500 kg", C: 'Schwere Nutzfahrzeuge > 3500 kg',
D1: "Kleinbusse 9-16 Sitzplätze", D1: 'Kleinbusse 9-16 Sitzplätze',
D: "Busse > 8 Sitzplätze", D: 'Busse > 8 Sitzplätze',
BE: "Fahrzeugklasse B mit Anhänger", BE: 'Fahrzeugklasse B mit Anhänger',
C1E: "Fahrzeugklasse C1 mit Anhänger", C1E: 'Fahrzeugklasse C1 mit Anhänger',
CE: "Fahrzeugklasse C mit Anhänger", CE: 'Fahrzeugklasse C mit Anhänger',
D1E: "Fahrzeugklasse D1 mit Anhänger", D1E: 'Fahrzeugklasse D1 mit Anhänger',
DE: "Fahrzeugklasse D mit Anhänger", DE: 'Fahrzeugklasse D mit Anhänger',
L: "Land-, Forstwirtschaftsfahrzeuge, Stapler max 40km/h", L: 'Land-, Forstwirtschaftsfahrzeuge, Stapler max 40km/h',
T: "Land-, Forstwirtschaftsfahrzeuge, Stapler max 60km/h", T: 'Land-, Forstwirtschaftsfahrzeuge, Stapler max 60km/h'
}, },
cancel: "Abbrechen", users: 'Mitglieder',
confirm: "Bestätigen", user: {
mandate_date_signed: "Mandatserteilungsdatum", login: 'Nutzer Anmeldung',
licence_categories: "Führerscheinklassen", edit: 'Nutzer bearbeiten',
subscription_model: "Mitgliedschatfsmodell", create: 'Nutzer erstellen',
licence: "Führerschein", user: 'Nutzer',
licence_number: "Führerscheinnummer", member: 'Mitglied',
issued_date: "Ausgabedatum", management: 'Mitgliederverwaltung',
expiration_date: "Ablaufdatum", id: 'Mitgliedsnr',
country: "Land", first_name: 'Vorname',
monthly_fee: "Monatliche Gebühr", last_name: 'Nachname',
hourly_rate: "Stundensatz", phone: 'Telefonnummer',
details: "Details", dateofbirth: 'Geburtstag',
conditions: "Bedingungen", email: 'Email',
user_role: "Nutzerrolle", membership: 'Mitgliedschaft',
unknown: "Unbekannt", bank_account: 'Kontodaten',
notes: "Notizen", status: 'Status',
address: "Straße & Hausnummer", role: 'Nutzerrolle',
city: "Wohnort", supporter: 'Sponsor',
zip_code: "PLZ", opponent: 'Unfallgegner'
forgot_password: "Passwort vergessen?", },
password: "Passwort", subscriptions: {
password_repeat: "Passwort wiederholen", name: 'Modellname',
email: "Email", edit: 'Modell bearbeiten',
company: "Firma", create: 'Modell erstellen',
login: "Anmeldung", subscription: 'Tarifmodell',
user: "Nutzer", subscriptions: 'Tarifmodelle',
user_login: "Nutzer Anmeldung", conditions: 'Bedingungen',
user_edit: "Nutzer bearbeiten", monthly_fee: 'Monatliche Gebühr',
profile: "Profil", hourly_rate: 'Stundensatz',
membership: "Mitgliedschaft", included_hours_per_year: 'Inkludierte Stunden pro Jahr',
bankaccount: "Kontodaten", included_hours_per_month: 'Inkludierte Stunden pro Monat'
first_name: "Vorname", },
last_name: "Nachname", car: {
phone: "Telefonnummer", car: 'Fahrzeug',
birth_date: "Geburtstag", model: 'Modell',
status: "Status", brand: 'Marke',
start: "Beginn", licence_plate: 'Kennzeichen',
end: "Ende", edit: 'Fahrzeug bearbeiten',
parent_member_id: "Hauptmitgliedsnr.", create: 'Fahrzeug hinzufügen',
bank_account_holder: "Kontoinhaber", damages: 'Schäden',
bank_name: "Bank", start_date: 'Anschaffungsdatum',
iban: "IBAN", end_date: 'Leasingende',
bic: "BIC", leasing_rate: 'Leasingrate'
mandate_reference: "SEPA Mandat", },
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 { export default {
userStatus: { userStatus: {
1: "Unverified", 1: 'Not Verified',
2: "Verified", 2: 'Deactivated',
3: "Active", 3: 'Verified',
4: "Passive", 4: 'System Access',
5: "Disabled", 5: 'Passive'
}, },
userRole: { userRole: {
0: "Member", 0: 'Sponsor',
1: "Viewer", 1: 'Member',
4: "Editor", 2: 'Viewer',
8: "Admin", 4: 'Editor',
8: 'Administrator'
}, },
placeholder: {
password: 'Enter password...',
email: 'Enter email address...',
company: 'Enter company name...',
first_name: 'Enter first name...',
last_name: 'Enter last name...',
phone: 'Enter phone number...',
address: 'Enter street and house number...',
zip_code: 'Enter postal code...',
city: 'Enter city...',
bank_name: 'Enter bank name...',
parent_member_id: 'Enter parent member ID...',
bank_account_holder: 'Enter name...',
iban: 'Enter IBAN...',
bic: 'Enter BIC (for non-German accounts)...',
mandate_reference: 'Enter SEPA mandate reference...',
notes: 'Your notes about {name}...',
licence_number: 'On the drivers licence under field 5',
issued_date: 'Issue date under field 4a',
expiration_date: 'Expiration date under field 4b',
issuing_country: 'Issuing country',
subscription_name: 'Subscription model name',
subscription_details: 'Describe the subscription model...',
subscription_conditions: 'Describe the usage conditions...',
search: 'Search...'
},
validation: {
required: 'Input required',
password: 'Password too short, at least 8 characters',
password_match: 'Passwords do not match!',
phone: 'Invalid format (+491738762387 or 0173850698)',
zip_code: 'Invalid postal code (Only German locations are allowed)',
bic: 'Invalid BIC',
iban: 'Invalid IBAN',
date: 'Please enter a date',
email: 'Invalid email address',
licence: 'Number too short (11 characters)'
},
server: {
general: 'General',
error: {
invalid_json: 'Invalid JSON data',
no_auth_token: 'Unauthorized, missing or invalid auth token',
jwt_parsing_error: 'Unauthorized, auth token could not be read',
unauthorized: 'You are not authorized to perform this action',
internal_server_error:
'Damn, error on our side, try again, then contact someone from the organization.',
not_possible: 'Operation not possible.',
not_found: 'Could not be found.',
in_use: 'Is in use',
undelivered_verification_mail:
'Registration successful, but the verification email could not be sent. Please contact the organization to verify your email address and activate your account.'
},
validation: {
invalid: 'Invalid',
invalid_user_id: 'Invalid user ID',
invalid_subscription: 'Model not found',
user_not_found: '{field} could not be found',
invalid_user_data: 'Invalid user data',
user_not_found_or_wrong_password: 'Does not exist or wrong password',
email_already_registered: 'A member has already been created with this email address.',
password_already_changed: 'The password has already been changed.',
alphanumunicode: 'Contains disallowed characters',
safe_content: 'I see what you did there! Do not cross this line!',
iban: 'Invalid. Format: DE07123412341234123412',
bic: 'Invalid. Format: BELADEBEXXX',
email: 'Invalid format',
number: 'Is not a number',
euDriversLicence: 'Is not a European drivers licence',
lte: 'Is too large/new',
gt: 'Is too small/old',
required: 'Field is required',
image: 'This is not an image',
alphanum: 'Contains invalid characters',
user_disabled: 'User is disabled',
duplicate: 'Already exists...',
alphaunicode: 'Must consist only of letters',
too_soon: 'Too soon'
}
},
licenceCategory: {
AM: 'Mopeds and light four-wheeled vehicles (50cc, max 45 km/h)',
A1: 'Light motorcycles (125cc)',
A2: 'Medium-power motorcycles (max 35 kW)',
A: 'Motorcycles',
B: 'Motor vehicles ≤ 3500 kg, ≤ 8 seats',
C1: 'Medium-heavy vehicles - 7500 kg',
C: 'Heavy commercial vehicles > 3500 kg',
D1: 'Minibuses with 9-16 seats',
D: 'Buses > 8 seats',
BE: 'Vehicle class B with trailer',
C1E: 'Vehicle class C1 with trailer',
CE: 'Vehicle class C with trailer',
D1E: 'Vehicle class D1 with trailer',
DE: 'Vehicle class D with trailer',
L: 'Agricultural, forestry vehicles, forklifts max 40 km/h',
T: 'Agricultural, forestry vehicles, forklifts max 60 km/h'
},
users: 'Members',
user: {
login: 'User Login',
edit: 'Edit User',
create: 'Create User',
user: 'User',
management: 'Member Management',
id: 'Member ID',
first_name: 'First Name',
last_name: 'Last Name',
phone: 'Phone Number',
dateofbirth: 'Date of Birth',
email: 'Email',
status: 'Status',
role: 'User Role',
supporter: 'Sponsor'
},
subscriptions: {
name: 'Model Name',
edit: 'Edit Model',
create: 'Create Model',
subscription: 'Subscription Model',
subscriptions: 'Subscription Models',
conditions: 'Conditions',
monthly_fee: 'Monthly Fee',
hourly_rate: 'Hourly Rate',
included_hours_per_year: 'Included Hours Per Year',
included_hours_per_month: 'Included Hours Per Month'
},
loading: {
user_data: 'Loading user data',
subscription_data: 'Loading model data',
please_wait: 'Please wait...',
updating: 'Updating...'
},
dialog: {
user_deletion: 'Should the user {firstname} {lastname} really be deleted?',
subscription_deletion: 'Should the subscription model {name} really be deleted?'
},
cancel: 'Cancel',
confirm: 'Confirm',
actions: 'Actions',
edit: 'Edit',
delete: 'Delete',
search: 'Search:',
name: 'Name',
supporter: 'Sponsors',
mandate_date_signed: 'Mandate Signing Date',
licence_categories: 'Drivers licence Categories',
subscription: 'Membership Model',
licence: 'Drivers licence',
licence_number: 'Drivers licence Number',
issued_date: 'Issue Date',
expiration_date: 'Expiration Date',
country: 'Country',
details: 'Details',
unknown: 'Unknown',
notes: 'Notes',
address: 'Street & House Number',
city: 'City',
zip_code: 'ZIP Code',
forgot_password: 'Forgot Password?',
password: 'Password',
confirm_password: 'Repeat Password',
password_changed: 'Password successfully changed.',
change_password: 'Change Password',
password_change_requested: 'Password change request sent... Please check your inbox.',
company: 'Company',
login: 'Login',
profile: 'Profile',
membership: 'Membership',
bank_account: 'Bank Account',
status: 'Status',
start: 'Start',
end: 'End',
parent_member_id: 'Parent Member ID',
bank_account_holder: 'Account Holder',
bank_name: 'Bank Name',
iban: 'IBAN',
bic: 'BIC',
mandate_reference: 'SEPA Mandate',
payments: 'Payments',
add_new: 'New',
email_sent: 'Email has been sent...',
payment: {
id: 'Payment ID',
amount: 'Amount',
date: 'Date',
status: 'Status'
},
subscriptionStatus: {
pending: 'Pending',
completed: 'Completed',
failed: 'Failed',
cancelled: 'Cancelled'
}
}; };

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
// @ts-nocheck import { quintOut } from 'svelte/easing';
import { quintOut } from "svelte/easing"; import { crossfade } from 'svelte/transition';
import { crossfade } from "svelte/transition";
export const [send, receive] = crossfade({ export const [send, receive] = crossfade({
duration: (d) => Math.sqrt(d * 200), duration: (d) => Math.sqrt(d * 200),
@@ -8,7 +7,7 @@ export const [send, receive] = crossfade({
// eslint-disable-next-line no-unused-vars // eslint-disable-next-line no-unused-vars
fallback(node, params) { fallback(node, params) {
const style = getComputedStyle(node); const style = getComputedStyle(node);
const transform = style.transform === "none" ? "" : style.transform; const transform = style.transform === 'none' ? '' : style.transform;
return { return {
duration: 600, duration: 600,
@@ -16,9 +15,9 @@ export const [send, receive] = crossfade({
css: (t) => ` css: (t) => `
transform: ${transform} scale(${t}); transform: ${transform} scale(${t});
opacity: ${t} opacity: ${t}
`, `
}; };
}, }
}); });
/** /**
@@ -37,9 +36,7 @@ export const isValidEmail = (email) => {
* @param {string} password - The password to validate * @param {string} password - The password to validate
*/ */
export const isValidPasswordStrong = (password) => { export const isValidPasswordStrong = (password) => {
const strongRegex = new RegExp( const strongRegex = new RegExp('^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})');
"^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})"
);
return strongRegex.test(password.trim()); return strongRegex.test(password.trim());
}; };
@@ -50,7 +47,7 @@ export const isValidPasswordStrong = (password) => {
*/ */
export const isValidPasswordMedium = (password) => { export const isValidPasswordMedium = (password) => {
const mediumRegex = new RegExp( const mediumRegex = new RegExp(
"^(((?=.*[a-z])(?=.*[A-Z]))|((?=.*[a-z])(?=.*[0-9]))|((?=.*[A-Z])(?=.*[0-9])))(?=.{6,})" '^(((?=.*[a-z])(?=.*[A-Z]))|((?=.*[a-z])(?=.*[0-9]))|((?=.*[A-Z])(?=.*[0-9])))(?=.{6,})'
); );
return mediumRegex.test(password.trim()); return mediumRegex.test(password.trim());
@@ -69,15 +66,125 @@ export function isEmpty(obj) {
return true; return true;
} }
/**
*
* @param {string} dateString
* @returns string
*/
export function toRFC3339(dateString) {
if (!dateString || dateString == '') dateString = '0001-01-01T00:00:00.000Z';
const date = new Date(dateString);
return date.toISOString();
}
/**
*
* @param {string} dateString
* @returns string
*/
export function fromRFC3339(dateString) {
if (!dateString) dateString = '0001-01-01T00:00:00.000Z';
const date = new Date(dateString);
return date.toISOString().split('T')[0];
}
/**
*
* @param {App.Types['car']} car - The car object to format
*/
export function carDatesFromRFC3339(car) {
car.end_date = fromRFC3339(car.end_date);
car.start_date = fromRFC3339(car.start_date);
car.insurances?.forEach((insurance) => {
insurance.start_date = fromRFC3339(insurance.start_date);
insurance.end_date = fromRFC3339(insurance.end_date);
});
}
/**
*
* @param {App.Types['car']} car - The car object to format
*/
export function carDatesToRFC3339(car) {
car.end_date = toRFC3339(car.end_date);
car.start_date = toRFC3339(car.start_date);
car.insurances?.forEach((insurance) => {
insurance.start_date = toRFC3339(insurance.start_date);
insurance.end_date = toRFC3339(insurance.end_date);
});
}
/**
*
* @param {App.Locals['user']} user - The user object to format
*/
export function userDatesFromRFC3339(user) {
if (user.dateofbirth) {
user.dateofbirth = fromRFC3339(user.dateofbirth);
}
if (user.membership) {
if (user.membership.start_date) {
user.membership.start_date = fromRFC3339(user.membership.start_date);
}
if (user.membership.end_date) {
user.membership.end_date = fromRFC3339(user.membership.end_date);
}
}
if (user.licence?.issued_date) {
user.licence.issued_date = fromRFC3339(user.licence.issued_date);
}
if (user.licence?.expiration_date) {
user.licence.expiration_date = fromRFC3339(user.licence.expiration_date);
}
if (user.bank_account && user.bank_account.mandate_date_signed) {
user.bank_account.mandate_date_signed = fromRFC3339(user.bank_account.mandate_date_signed);
}
}
/**
* Formats dates in the user object to RFC3339 format
* @param {App.Locals['user']} user - The user object to format
*/
export function userDatesToRFC3339(user) {
if (user.dateofbirth) {
user.dateofbirth = toRFC3339(user.dateofbirth);
}
if (user.membership) {
if (user.membership.start_date) {
user.membership.start_date = toRFC3339(user.membership.start_date);
}
if (user.membership.end_date) {
user.membership.end_date = toRFC3339(user.membership.end_date);
}
}
if (user.licence?.issued_date) {
user.licence.issued_date = toRFC3339(user.licence.issued_date);
}
if (user.licence?.expiration_date) {
user.licence.expiration_date = toRFC3339(user.licence.expiration_date);
}
if (user.bank_account && user.bank_account.mandate_date_signed) {
user.bank_account.mandate_date_signed = toRFC3339(user.bank_account.mandate_date_signed);
}
}
/**
*
* @param {string | Array<{field: string, key: string}> | Record<string, {key: string}>} obj - The error object to format
* @returns {Array<{
* field: string,
* key: string,
* id: number
* }> } Array of formatted error objects
*/
export function formatError(obj) { export function formatError(obj) {
const errors = []; const errors = [];
if (typeof obj === "object" && obj !== null) { if (typeof obj === 'object') {
if (Array.isArray(obj)) { if (Array.isArray(obj)) {
obj.forEach((error) => { obj.forEach((error) => {
errors.push({ errors.push({
field: error.field, field: error.field,
key: error.key, key: error.key,
id: Math.random() * 1000, id: Math.random() * 1000
}); });
}); });
} else { } else {
@@ -85,16 +192,46 @@ export function formatError(obj) {
errors.push({ errors.push({
field: field, field: field,
key: obj[field].key, key: obj[field].key,
id: Math.random() * 1000, id: Math.random() * 1000
}); });
}); });
} }
} else { } else {
errors.push({ errors.push({
field: "general", field: 'general',
key: obj, key: obj,
id: 0, id: 0
}); });
} }
return errors; 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) dateString = "0001-01-01T00:00:00.000Z";
const date = new Date(dateString);
return date.toISOString();
}

View File

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

View File

@@ -2,5 +2,7 @@
export async function load({ locals }) { export async function load({ locals }) {
return { return {
user: locals.user, user: locals.user,
licence_categories: locals.licence_categories,
subscriptions: locals.subscriptions
}; };
} }

View File

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

View File

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

View File

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

View File

@@ -1,66 +0,0 @@
import { BASE_API_URI } from "$lib/utils/constants";
/** @type {import('./$types').LayoutServerLoad} */
export async function load({ cookies, fetch, locals }) {
const jwt = cookies.get("jwt");
try {
// Fetch user data, subscriptions, and licence categories in parallel
const [subscriptionsResponse, licenceCategoriesResponse] =
await Promise.all([
fetch(`${BASE_API_URI}/backend/membership/subscriptions`, {
credentials: "include",
headers: { Cookie: `jwt=${jwt}` },
}),
fetch(`${BASE_API_URI}/backend/licence/categories`, {
credentials: "include",
headers: { Cookie: `jwt=${jwt}` },
}),
]);
// Check if any of the responses are not ok
if (!subscriptionsResponse.ok || !licenceCategoriesResponse.ok) {
cookies.delete("jwt", { path: "/" });
throw new Error("One or more API requests failed");
}
// Parse the JSON responses
const [subscriptionsData, licence_categoriesData] = await Promise.all([
subscriptionsResponse.json(),
licenceCategoriesResponse.json(),
]);
// Check if the server sent a new token
const newToken =
subscriptionsResponse.headers.get("Set-Cookie") == null
? licenceCategoriesResponse.headers.get("Set-Cookie")
: subscriptionsResponse.headers.get("Set-Cookie");
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
});
}
}
console.dir(subscriptionsData);
console.dir(licence_categoriesData);
return {
user: locals.user,
subscriptions: subscriptionsData.subscriptions,
licence_categories: licence_categoriesData.licence_categories,
};
} catch (error) {
console.error("Error fetching data:", error);
// In case of any error, clear the JWT cookie
cookies.delete("jwt", { path: "/" });
return {
user: locals.user,
subscriptions: null,
licence_categories: null,
};
}
}

View File

@@ -1,13 +1,19 @@
import { BASE_API_URI } from "$lib/utils/constants"; import { BASE_API_URI } from '$lib/utils/constants';
import { formatError } from "$lib/utils/helpers"; import { formatError, userDatesFromRFC3339 } from '$lib/utils/helpers';
import { fail, redirect } from "@sveltejs/kit"; import { fail, redirect } from '@sveltejs/kit';
import { toRFC3339 } from "$lib/utils/utils"; 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} */ /** @type {import('./$types').PageServerLoad} */
export async function load({ locals, params }) { export async function load({ locals, params }) {
// redirect user if not logged in // redirect user if not logged in
if (!locals.user) { if (!locals.user) {
throw redirect(302, `/auth/login?next=/auth/about/${params.id}`); throw redirect(302, `${base}/auth/login?next=${base}/auth/about/${params.id}`);
} }
} }
@@ -24,82 +30,35 @@ export const actions = {
updateUser: async ({ request, fetch, cookies, locals }) => { updateUser: async ({ request, fetch, cookies, locals }) => {
let formData = await request.formData(); let formData = await request.formData();
const licenceCategories = formData const rawFormData = formDataToObject(formData);
.getAll("licence_categories[]") /** @type {{object: Partial<App.Locals['user']>, confirm_password: string}} */
.filter((value) => typeof value === "string") const rawData = {
.map((value) => { object: /** @type {Partial<App.Locals['user']>} */ (rawFormData.object),
try { confirm_password: rawFormData.confirm_password
return JSON.parse(value);
} catch (e) {
console.error("Failed to parse licence category:", value);
return null;
}
})
.filter(Boolean);
/** @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: toRFC3339(
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")),
},
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: licenceCategories,
},
}; };
// Remove undefined or null properties // confirm password matches and is not empty. Otherwise set password to empty string
const cleanUpdateData = JSON.parse( if (
JSON.stringify(updateData), rawData.object.password &&
(key, value) => (value !== null && value !== "" ? value : undefined) rawData.confirm_password &&
); (rawData.object.password != rawData.confirm_password || rawData.object.password.trim() == '')
console.dir(formData); ) {
console.dir(cleanUpdateData); rawData.object.password = '';
const apiURL = `${BASE_API_URI}/backend/users/update/`; }
const processedData = processUserFormData(rawData.object);
// const isCreating = !processedData.user.id || processedData.user.id === 0;
// console.log('Is creating: ', isCreating);
const apiURL = `${BASE_API_URI}/auth/users/`;
/** @type {RequestInit} */ /** @type {RequestInit} */
const requestUpdateOptions = { const requestUpdateOptions = {
method: "PATCH", method: 'PUT',
credentials: "include", credentials: 'include',
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json',
Cookie: `jwt=${cookies.get("jwt")}`, Cookie: `jwt=${cookies.get('jwt')}`
}, },
body: JSON.stringify(cleanUpdateData), body: JSON.stringify(processedData)
}; };
const res = await fetch(apiURL, requestUpdateOptions); const res = await fetch(apiURL, requestUpdateOptions);
@@ -111,33 +70,10 @@ export const actions = {
const response = await res.json(); const response = await res.json();
locals.user = response; locals.user = response;
userDatesFromRFC3339(locals.user);
// Format dates throw redirect(303, `${base}/auth/about/${response.id}`);
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];
}
if (locals.user.bank_account?.mandate_date_signed) {
locals.user.bank_account.mandate_date_signed =
locals.user.bank_account.mandate_date_signed.split("T")[0];
}
if (locals.user.licence?.issued_date) {
locals.user.licence.issued_date =
locals.user.licence.issued_date.split("T")[0];
}
if (locals.user.licence?.expiration_date) {
locals.user.licence.expiration_date =
locals.user.licence.expiration_date.split("T")[0];
}
throw redirect(303, `/auth/about/${response.id}`);
}, },
/** /**
* *
* @param request - The request object * @param request - The request object
@@ -151,11 +87,11 @@ export const actions = {
/** @type {RequestInit} */ /** @type {RequestInit} */
const requestInitOptions = { const requestInitOptions = {
method: "POST", method: 'POST',
headers: { headers: {
Cookie: `jwt=${cookies.get("jwt")}`, Cookie: `jwt=${cookies.get('jwt')}`
}, },
body: formData, body: formData
}; };
const res = await fetch(`${BASE_API_URI}/file/upload/`, requestInitOptions); const res = await fetch(`${BASE_API_URI}/file/upload/`, requestInitOptions);
@@ -170,7 +106,7 @@ export const actions = {
return { return {
success: true, success: true,
profile_picture: response[""], profile_picture: response['']
}; };
}, },
@@ -187,11 +123,11 @@ export const actions = {
/** @type {RequestInit} */ /** @type {RequestInit} */
const requestInitOptions = { const requestInitOptions = {
method: "DELETE", method: 'DELETE',
headers: { headers: {
Cookie: `jwt=${cookies.get("jwt")}`, Cookie: `jwt=${cookies.get('jwt')}`
}, },
body: formData, body: formData
}; };
const res = await fetch(`${BASE_API_URI}/file/delete/`, requestInitOptions); const res = await fetch(`${BASE_API_URI}/file/delete/`, requestInitOptions);
@@ -204,7 +140,7 @@ export const actions = {
return { return {
success: true, success: true,
profile_picture: "", profile_picture: ''
}; };
}, }
}; };

View File

@@ -1,18 +1,14 @@
<script> <script>
import SmallLoader from "$lib/components/SmallLoader.svelte"; import Modal from '$lib/components/Modal.svelte';
import Modal from "$lib/components/Modal.svelte"; import UserEditForm from '$lib/components/UserEditForm.svelte';
import { onMount } from "svelte"; import { onMount } from 'svelte';
import { applyAction, enhance } from "$app/forms"; import { page } from '$app/stores';
import { page } from "$app/stores"; import { t } from 'svelte-i18n';
import { receive, send } from "$lib/utils/helpers";
import { t } from "svelte-i18n";
import { fly } from "svelte/transition";
import UserEditForm from "$lib/components/UserEditForm.svelte";
/** @type {import('./$types').ActionData} */ /** @type {import('./$types').ActionData} */
export let form; export let form;
$: ({ user, subscriptions, licence_categories } = $page.data); $: ({ user, licence_categories, subscriptions } = $page.data);
let showModal = false; let showModal = false;
@@ -37,12 +33,10 @@
<span class="value block-value"> <span class="value block-value">
<span <span
>{$t(`userStatus.${user.status}`, { >{$t(`userStatus.${user.status}`, {
default: "unknown status", default: 'unknown status'
})}</span })}</span
> >
<span <span>{$t(`userRole.${user.role_id}`, { default: 'unknown' })}</span>
>{$t(`userRole.${user.role_id}`, { default: "unknown role" })}</span
>
</span> </span>
</h3> </h3>
{/if} {/if}
@@ -71,15 +65,15 @@
<span class="value">{user.phone}</span> <span class="value">{user.phone}</span>
</h3> </h3>
{/if} {/if}
{#if user.date_of_birth} {#if user.dateofbirth}
<h3 class="hero-subtitle subtitle info-row"> <h3 class="hero-subtitle subtitle info-row">
<span class="label">Geburtstag:</span> <span class="label">Geburtstag:</span>
<span class="value">{user.date_of_birth}</span> <span class="value">{user.dateofbirth}</span>
</h3> </h3>
{/if} {/if}
{#if user.notes} {#if user.notes}
<h3 class="hero-subtitle subtitle info-row"> <h3 class="hero-subtitle subtitle info-row">
<span class="label">{$t("notes")}:</span> <span class="label">{$t('notes')}:</span>
<span class="value">{user.notes}</span> <span class="value">{user.notes}</span>
</h3> </h3>
{/if} {/if}
@@ -97,7 +91,9 @@
{user} {user}
{subscriptions} {subscriptions}
{licence_categories} {licence_categories}
on:close={close}
on:cancel={close} on:cancel={close}
editor={user}
/> />
</Modal> </Modal>
{/if} {/if}
@@ -120,6 +116,11 @@
align-items: start; align-items: start;
text-align: left; text-align: left;
margin-top: 1rem; margin-top: 1rem;
color: var(--text);
background-color: var(--surface0);
padding: 1.5rem;
border-radius: 8px;
border: 1px solid var(--surface1);
} }
.info-row { .info-row {
@@ -131,18 +132,21 @@
font-weight: bold; font-weight: bold;
text-align: left; text-align: left;
padding-right: 1rem; padding-right: 1rem;
color: var(--lavender);
} }
.value { .value {
margin: 0; margin: 0;
font-size: 1.2rem; font-size: 1.2rem;
text-align: left; text-align: left;
color: var(--text);
} }
.block-value { .block-value {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
color: var(--subtext0);
} }
.hero-buttons-container { .hero-buttons-container {

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

View File

@@ -1,8 +1,9 @@
<script> <script>
import { applyAction, enhance } from "$app/forms"; import { applyAction, enhance } from '$app/forms';
import { page } from "$app/stores"; import { base } from '$app/paths';
import { receive, send } from "$lib/utils/helpers"; import { page } from '$app/stores';
import { t } from "svelte-i18n"; import { receive, send } from '$lib/utils/helpers';
import { t } from 'svelte-i18n';
/** @type {import('./$types').ActionData} */ /** @type {import('./$types').ActionData} */
export let form; export let form;
@@ -14,26 +15,21 @@
}; };
}; };
let message = ""; let message = '';
if ($page.url.searchParams.get("message")) { if ($page.url.searchParams.get('message')) {
message = $page.url.search.split("=")[1].replaceAll("%20", " "); message = $page.url.search.split('=')[1].replaceAll('%20', ' ');
} }
</script> </script>
<div class="container"> <div class="container">
<form <form class="content" method="POST" action="?/login" use:enhance={handleLogin}>
class="content" <h1 class="step-title">{$t('user.login')}</h1>
method="POST"
action="?/login"
use:enhance={handleLogin}
>
<h1 class="step-title">{$t("user_login")}</h1>
{#if form?.errors} {#if form?.errors}
{#each form?.errors as error (error.id)} {#each form?.errors as error (error.id)}
<h4 <h4
class="step-subtitle warning" class="step-subtitle warning"
in:receive={{ key: error.id }} in:receive|global={{ key: error.id }}
out:send={{ key: error.id }} out:send|global={{ key: error.id }}
> >
{$t(error.key)} {$t(error.key)}
</h4> </h4>
@@ -41,39 +37,29 @@
{/if} {/if}
{#if message} {#if message}
<h4 class="step-subtitle">{message}</h4> <h4 class="step-subtitle">{$t(message)}</h4>
{/if} {/if}
<input <input type="hidden" name="next" value={$page.url.searchParams.get('next')} />
type="hidden"
name="next"
value={$page.url.searchParams.get("next")}
/>
<div class="input-box"> <div class="input-box">
<span class="label">{$t("email")}:</span> <span class="label">{$t('user.email')}:</span>
<input <input class="input" type="email" name="email" placeholder="{$t('placeholder.email')} " />
class="input"
type="email"
name="email"
placeholder="{$t('placeholder.email')} "
/>
</div> </div>
<div class="input-box"> <div class="input-box">
<span class="label">{$t("password")}:</span> <span class="label">{$t('password')}:</span>
<div class="input-wrapper"> <div class="input-wrapper">
<input <input
class="input" class="input"
type="password" type="password"
name="password" name="password"
placeholder={$t("placeholder.password")} placeholder={$t('placeholder.password')}
/> />
<a href="/auth/password/request-change" class="forgot-password" <a href={`${base}/auth/password/change`} class="forgot-password">{$t('forgot_password')}?</a
>{$t("forgot_password")}?</a
> >
</div> </div>
</div> </div>
<div class="btn-container"> <div class="btn-container">
<button class="button-dark">{$t("login")} </button> <button class="button-dark">{$t('login')} </button>
</div> </div>
</form> </form>
</div> </div>
@@ -85,6 +71,7 @@
align-items: flex-end; align-items: flex-end;
width: 100%; width: 100%;
max-width: 444px; max-width: 444px;
margin-top: 30px;
} }
.forgot-password { .forgot-password {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -12,16 +12,21 @@ require (
) )
require ( require (
github.com/alexedwards/argon2id v1.0.0
github.com/gin-contrib/cors v1.7.2 github.com/gin-contrib/cors v1.7.2
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/kelseyhightower/envconfig v1.4.0 github.com/kelseyhightower/envconfig v1.4.0
github.com/mocktools/go-smtp-mock/v2 v2.3.1 github.com/mocktools/go-smtp-mock/v2 v2.3.1
github.com/stretchr/testify v1.9.0 github.com/stretchr/testify v1.9.0
github.com/wagslane/go-password-validator v0.3.0
) )
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/kr/text v0.2.0 // indirect github.com/kr/text v0.2.0 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
) )
require ( require (

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,14 +2,15 @@ package controllers
import ( import (
"GoMembership/internal/services" "GoMembership/internal/services"
"GoMembership/pkg/logger" "GoMembership/internal/utils"
"GoMembership/pkg/errors"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type LicenceController struct { type LicenceController struct {
Service services.LicenceService Service services.LicenceServiceInterface
} }
func (lc *LicenceController) GetAllCategories(c *gin.Context) { func (lc *LicenceController) GetAllCategories(c *gin.Context) {
@@ -17,14 +18,9 @@ func (lc *LicenceController) GetAllCategories(c *gin.Context) {
categories, err := lc.Service.GetAllCategories() categories, err := lc.Service.GetAllCategories()
if err != nil { if err != nil {
logger.Error.Printf("Error retrieving licence categories: %v", err) utils.RespondWithError(c, err, "Error retrieving licence categories", http.StatusInternalServerError, errors.Responses.Fields.Licences, errors.Responses.Keys.InternalServerError)
c.JSON(http.StatusInternalServerError, gin.H{"errors": []gin.H{{
"field": "general",
"key": "validation.internal_server_error",
}}})
return return
} }
logger.Error.Printf("categories: %v", categories)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"licence_categories": categories, "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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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