Compare commits

..

167 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
199 changed files with 15966 additions and 42538 deletions

6
.gitignore vendored
View File

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

View File

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

37
config.json.template Normal file
View File

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

2
frontend/.env.template Normal file
View File

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

39
frontend/.gitignore vendored Normal file
View File

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

1
frontend/.npmrc Normal file
View File

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

4
frontend/.prettierignore Normal file
View File

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

15
frontend/.prettierrc Normal file
View File

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

14
frontend/Dockerfile Normal file
View File

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

View File

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

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

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

19
frontend/jsconfig.json Normal file
View File

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

4278
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
frontend/package.json Normal file
View File

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

View File

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

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

@@ -1,84 +1,137 @@
// See https://kit.svelte.dev/docs/types#app
interface Subscription {
id: number | -1;
name: string | "";
details?: string | "";
conditions?: string | "";
monthly_fee?: number | -1;
hourly_rate?: number | -1;
included_hours_per_year?: number | 0;
included_hours_per_month?: number | 0;
id: number | -1;
name: string | '';
details: string | '';
conditions: string | '';
monthly_fee: number | 0;
hourly_rate: number | 0;
included_hours_per_year: number | 0;
included_hours_per_month: number | 0;
}
interface Membership {
id: number | -1;
status: number | -1;
start_date: string | "";
end_date: string | "";
parent_member_id: number | -1;
subscription_model: Subscription;
id: number | -1;
status: number | -1;
start_date: string | '';
end_date: string | '';
parent_member_id: number | -1;
subscription: Subscription;
}
interface BankAccount {
id: number | -1;
mandate_date_signed: string | "";
bank: string | "";
account_holder_name: string | "";
iban: string | "";
bic: string | "";
mandate_reference: string | "";
id: number | -1;
mandate_date_signed: string | '';
bank: string | '';
account_holder_name: string | '';
iban: string | '';
bic: string | '';
mandate_reference: string | '';
}
interface Licence {
id: number | -1;
status: number | -1;
licence_number: string | "";
issued_date: string | "";
expiration_date: string | "";
country: string | "";
licence_categories: LicenceCategory[];
id: number | -1;
status: number | -1;
number: string | '';
issued_date: string | '';
expiration_date: string | '';
country: string | '';
categories: LicenceCategory[];
}
interface LicenceCategory {
id: number | -1;
category: string | "";
id: number | -1;
category: string | '';
}
interface User {
email: string | "";
first_name: string | "";
last_name: string | "";
phone: string | "";
notes: string | "";
address: string | "";
zip_code: string | "";
city: string | "";
status: number | -1;
id: number | -1;
role_id: number | -1;
date_of_birth: string | "";
company: string | "";
profile_picture: string | "";
payment_status: number | -1;
membership: Membership;
bank_account: BankAccount;
licence: Licence;
notes: string | "";
email: string | '';
first_name: string | '';
last_name: string | '';
password: string | '';
phone: string | '';
address: string | '';
zip_code: string | '';
city: string | '';
status: number | -1;
id: number | -1;
role_id: number | -1;
dateofbirth: string | '';
company: string | '';
membership: Membership | null;
bank_account: BankAccount | null;
licence: Licence | null;
notes: string | '';
}
interface Car {
id: number | -1;
name: string | '';
status: number | 0;
brand: string | '';
model: string | '';
price: number | 0;
rate: number | 0;
start_date: string | '';
end_date: string | '';
color: string | '';
licence_plate: string | '';
location: Location | null;
damages: Damage[] | null;
insurances: Insurance[] | null;
notes: string | '';
}
interface Location {
latitude: number | 0;
longitude: number | 0;
}
interface Damage {
id: number | -1;
name: string | '';
opponent: User | null;
driver_id: number | -1;
insurance: Insurance | null;
date: string | '';
notes: string | '';
}
interface Insurance {
id: number | -1;
company: string | '';
reference: string | '';
start_date: string | '';
end_date: string | '';
notes: string | '';
}
declare global {
namespace App {
// interface Error {}
interface Locals {
user: User;
users: User[];
subscriptions: Subscription[];
licence_categories: LicenceCategory[];
}
// interface PageData {}
// interface Platform {}
}
namespace App {
// interface Error {}
interface Locals {
user: User;
users: User[];
cars: Cars[];
subscriptions: Subscription[];
licence_categories: LicenceCategory[];
}
interface Types {
licenceCategory: LicenceCategory;
subscription: Subscription;
membership: Membership;
licence: Licence;
licenceCategory: LicenceCategory;
bankAccount: BankAccount;
car: Car;
insurance: Insurance;
location: Location;
damage: Damage;
}
// interface PageData {}
// interface Platform {}
}
}
export {};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,168 @@
<script>
import InputField from '$lib/components/InputField.svelte';
import SmallLoader from '$lib/components/SmallLoader.svelte';
import { createEventDispatcher } from 'svelte';
import { applyAction, enhance } from '$app/forms';
import { receive, send } from '$lib/utils/helpers';
import { t } from 'svelte-i18n';
import { defaultSubscription } from '$lib/utils/defaults';
const dispatch = createEventDispatcher();
/** @type {import('../../routes/auth/about/[id]/$types').ActionData} */
export let form;
/** @type {App.Locals['user'] } */
export let user;
/** @type {App.Types['subscription'] | null} */
export let subscription;
console.log('Opening subscription modal with:', subscription);
$: subscription = subscription || { ...defaultSubscription() };
$: isLoading = subscription === undefined || user === undefined;
let isUpdating = false;
/** @type {import('../../routes/auth/about/[id]/$types').SubmitFunction} */
const handleUpdate = async () => {
isUpdating = true;
return async ({ result }) => {
isUpdating = false;
if (result.type === 'success' || result.type === 'redirect') {
dispatch('close');
} else {
document.querySelector('.modal .container')?.scrollTo({ top: 0, behavior: 'smooth' });
}
await applyAction(result);
};
};
</script>
{#if isLoading}
<SmallLoader width={30} message={$t('loading.subscription_data')} />
{:else if user && subscription}
<form class="content" action="?/updateSubscription" method="POST" use:enhance={handleUpdate}>
<input name="susbscription[id]" type="hidden" bind:value={subscription.id} />
<h1 class="step-title" style="text-align: center;">
{subscription.id ? $t('subscriptions.edit') : $t('subscriptions.create')}
</h1>
{#if form?.errors}
{#each form?.errors as error (error.id)}
<h4
class="step-subtitle warning"
in:receive|global={{ key: error.id }}
out:send|global={{ key: error.id }}
>
{$t(error.field) + ': ' + $t(error.key)}
</h4>
{/each}
{/if}
<div class="tab-content" style="display: block">
<InputField
name="subscription[name]"
label={$t('subscriptions.name')}
bind:value={subscription.name}
placeholder={$t('placeholder.subscription_name')}
required={true}
readonly={subscription.id > 0}
/>
<InputField
name="subscription[details]"
label={$t('details')}
type="textarea"
bind:value={subscription.details}
placeholder={$t('placeholder.subscription_details')}
required={true}
/>
<InputField
name="subscription[conditions]"
type="textarea"
label={$t('subscriptions.conditions')}
bind:value={subscription.conditions}
placeholder={$t('placeholder.subscription_conditions')}
readonly={subscription.id > 0}
/>
<InputField
name="subscription[monthly_fee]"
type="number"
label={$t('subscriptions.monthly_fee')}
bind:value={subscription.monthly_fee}
placeholder={$t('placeholder.subscription_monthly_fee')}
required={true}
readonly={subscription.id > 0}
/>
<InputField
name="subscription[hourly_rate]"
type="number"
label={$t('subscriptions.hourly_rate')}
bind:value={subscription.hourly_rate}
required={true}
readonly={subscription.id > 0}
/>
<InputField
name="subscription[included_hours_per_year]"
type="number"
label={$t('subscriptions.included_hours_per_year')}
bind:value={subscription.included_hours_per_year}
readonly={subscription.id > 0}
/>
<InputField
name="included_hours_per_month"
type="number"
label={$t('subscriptions.included_hours_per_month')}
bind:value={subscription.included_hours_per_month}
readonly={subscription.id > 0}
/>
</div>
<div class="button-container">
{#if isUpdating}
<SmallLoader width={30} message={$t('loading.updating')} />
{:else}
<button type="button" class="button-dark" on:click={() => dispatch('cancel')}>
{$t('cancel')}</button
>
<button type="submit" class="button-dark">{$t('confirm')}</button>
{/if}
</div>
</form>
{/if}
<style>
.tab-content {
padding: 1rem;
border-radius: 0 0 3px 3px;
background-color: var(--surface0);
border: 1px solid var(--surface1);
margin-top: 1rem;
}
.button-container {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px;
margin-top: 1rem;
width: 100%;
}
.button-container button {
flex: 1 1 0;
min-width: 120px;
max-width: calc(50% - 5px);
background-color: var(--surface1);
color: var(--text);
border: 1px solid var(--overlay0);
transition: all 0.2s ease-in-out;
}
.button-container button:hover {
background-color: var(--surface2);
border-color: var(--lavender);
}
@media (max-width: 480px) {
.button-container button {
flex-basis: 100%;
max-width: none;
}
}
</style>

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,157 +1,264 @@
export default {
userStatus: {
1: "Nicht verifiziert",
2: "Verifiziert",
3: "Aktiv",
4: "Passiv",
5: "Deaktiviert",
},
userRole: {
0: "Mitglied",
1: "Betrachter",
4: "Bearbeiter",
8: "Administrator",
},
placeholder: {
password: "Passwort eingeben...",
email: "Emailadresse eingeben...",
company: "Firmennamen eingeben...",
first_name: "Vornamen eingeben...",
last_name: "Nachnamen eingeben...",
phone: "Telefonnummer eingeben...",
address: "Straße und Hausnummer eingeben...",
zip_code: "Postleitzahl eingeben...",
city: "Wohnort eingeben...",
bank_name: "Namen der Bank eingeben...",
parent_member_id: "Mitgliedsnr des Hauptmitglieds eingeben...",
bank_account_holder: "Namen eingeben...",
iban: "IBAN eingeben..",
bic: "BIC eingeben(Bei nicht deutschen Konten)...",
mandate_reference: "SEPA Mandatsreferenz eingeben..",
notes: "Deine Notizen zu {name}...",
licence_number: "Auf dem Führerschein unter Feld 5",
issued_date: "Ausgabedatum unter Feld 4a",
expiration_date: "Ablaufdatum unter Feld 4b",
issuing_country: "Ausstellendes Land",
},
validation: {
required: "Eingabe benötigt",
password: "Password zu kurz, mindestens 8 Zeichen",
password_match: "Passwörter stimmen nicht überein!",
phone: "Ungültiges Format(+491738762387 oder 0173850698)",
zip_code: "Ungültige Postleitzahl(Nur deutsche Wohnorte sind zulässig)",
bic: "Ungültige BIC",
iban: "Ungültige IBAN",
date: "Bitte geben Sie ein Datum ein",
email: "Ungültige Emailadresse",
licence: "Nummer zu kurz(11 Zeichen)",
},
server: {
error: {
invalid_json: "JSON Daten sind ungültig",
no_auth_token: "Nicht authorisiert, fehlender oder ungültiger Auth-Token",
jwt_parsing_error:
"Nicht authorisiert, Auth-Token konnte nicht gelesen werden",
unauthorized_update: "Sie sind nicht befugt dieses Update durchzuführen",
internal_server_error:
"Verdammt, fehler auf unserer Seite, probieren Sie es nochmal, danach rufen Sie nach Hilfe",
},
validation: {
no_user_id_provided: "Nutzer ID fehlt im Header",
invalid_subscription_model: "Model nicht gefunden",
user_not_found: "{field} konnte nicht gefunden werden",
invalid_user_data: "Nutzerdaten ungültig",
user_not_found_or_wrong_password:
"Existiert nicht oder falsches Passwort",
email_already_registered:
"Ein Mitglied wurde schon mit dieser Emailadresse erstellt.",
alphanumunicode: "beinhaltet nicht erlaubte Zeichen",
safe_content: "I see what you did there! Do not cross this line!",
iban: "Ungültig. Format: DE07123412341234123412",
bic: "Ungültig. Format: BELADEBEXXX",
email: "Format ungültig",
number: "Ist keine Nummer",
euDriversLicence: "Ist kein europäischer Führerschein",
lte: "Ist zu groß/neu",
gt: "Ist zu klein/alt",
required: "Feld wird benötigt",
image: "Dies ist kein Bild",
alphanum: "beinhaltet ungültige Zeichen",
alphaunicode: "darf nur aus Buchstaben bestehen",
},
},
licenceCategory: {
AM: "Mopeds und leichte vierrädrige Kraftfahrzeuge (50ccm, max 45km/h)",
A1: "Leichte Motorräder (125ccm)",
A2: "Motorräder mit mittlerer Leistung (max 35kW)",
A: "Motorräder",
B: "Kraftfahrzeuge ≤ 3500 kg, ≤ 8 Sitzplätze",
C1: "Mittelschwere Fahrzeuge -7500 kg",
C: "Schwere Nutzfahrzeuge > 3500 kg",
D1: "Kleinbusse 9-16 Sitzplätze",
D: "Busse > 8 Sitzplätze",
BE: "Fahrzeugklasse B mit Anhänger",
C1E: "Fahrzeugklasse C1 mit Anhänger",
CE: "Fahrzeugklasse C mit Anhänger",
D1E: "Fahrzeugklasse D1 mit Anhänger",
DE: "Fahrzeugklasse D mit Anhänger",
L: "Land-, Forstwirtschaftsfahrzeuge, Stapler max 40km/h",
T: "Land-, Forstwirtschaftsfahrzeuge, Stapler max 60km/h",
},
user: {
login: "Nutzer Anmeldung",
edit: "Nutzer bearbeiten",
user: "Nutzer",
management: "Mitgliederverwaltung",
id: "Mitgliedsnr",
name: "Name",
email: "Email",
status: "Status",
role: "Nutzerrolle",
},
cancel: "Abbrechen",
confirm: "Bestätigen",
actions: "Aktionen",
edit: "Bearbeiten",
delete: "Löschen",
mandate_date_signed: "Mandatserteilungsdatum",
licence_categories: "Führerscheinklassen",
subscription_model: "Mitgliedschatfsmodell",
licence: "Führerschein",
licence_number: "Führerscheinnummer",
issued_date: "Ausgabedatum",
expiration_date: "Ablaufdatum",
country: "Land",
monthly_fee: "Monatliche Gebühr",
hourly_rate: "Stundensatz",
details: "Details",
conditions: "Bedingungen",
unknown: "Unbekannt",
notes: "Notizen",
address: "Straße & Hausnummer",
city: "Wohnort",
zip_code: "PLZ",
forgot_password: "Passwort vergessen?",
password: "Passwort",
password_repeat: "Passwort wiederholen",
email: "Email",
company: "Firma",
login: "Anmeldung",
profile: "Profil",
membership: "Mitgliedschaft",
bankaccount: "Kontodaten",
first_name: "Vorname",
last_name: "Nachname",
name: "Name",
phone: "Telefonnummer",
birth_date: "Geburtstag",
status: "Status",
start: "Beginn",
end: "Ende",
parent_member_id: "Hauptmitgliedsnr.",
bank_account_holder: "Kontoinhaber",
bank_name: "Bank",
iban: "IBAN",
bic: "BIC",
mandate_reference: "SEPA Mandat",
userStatus: {
1: 'Nicht verifiziert',
2: 'Deaktiviert',
3: 'Verifiziert',
4: 'Systemzugang',
5: 'Passiv'
},
userRole: {
'-1': 'Unfallgegner',
0: 'Sponsor',
1: 'Mitglied',
2: 'Betrachter',
4: 'Bearbeiter',
8: 'Administrator'
},
placeholder: {
car_name: 'Hat das Fahrzeug einen Namen?',
car_brand: 'Fahrzeughersteller eingeben...',
car_model: 'Fahrzeugmodell eingeben...',
car_color: 'Fahrzeugfarbe eingeben...',
car_licence_plate: 'Fahrzeugkennzeichen eingeben...',
insurance_reference: 'Versicherungsnummer eingeben...',
password: 'Passwort eingeben...',
email: 'Emailadresse eingeben...',
company: 'Firmennamen eingeben...',
first_name: 'Vornamen eingeben...',
last_name: 'Nachnamen eingeben...',
phone: 'Telefonnummer eingeben...',
address: 'Straße und Hausnummer eingeben...',
zip_code: 'Postleitzahl eingeben...',
city: 'Wohnort eingeben...',
bank_name: 'Namen der Bank eingeben...',
parent_member_id: 'Mitgliedsnr des Hauptmitglieds eingeben...',
bank_account_holder: 'Namen eingeben...',
iban: 'IBAN eingeben..',
bic: 'BIC eingeben(Bei nicht deutschen Konten)...',
mandate_reference: 'SEPA Mandatsreferenz eingeben..',
notes: 'Deine Notizen zu {name}...',
licence_number: 'Auf dem Führerschein unter Feld 5',
issued_date: 'Ausgabedatum unter Feld 4a',
expiration_date: 'Ablaufdatum unter Feld 4b',
issuing_country: 'Ausstellendes Land',
subscription_name: 'Name des Tarifmodells',
subscription_details: 'Beschreibe das Tarifmodell...',
subscription_conditions: 'Beschreibe die Bedingungen zur Nutzung...',
search: 'Suchen...'
},
validation: {
required: 'Eingabe benötigt',
password: 'Password zu kurz, mindestens 8 Zeichen',
password_match: 'Passwörter stimmen nicht überein!',
phone: 'Ungültiges Format(+491738762387 oder 0173850698)',
zip_code: 'Ungültige Postleitzahl(Nur deutsche Wohnorte sind zulässig)',
bic: 'Ungültige BIC',
iban: 'Ungültige IBAN',
date: 'Bitte geben Sie ein Datum ein',
email: 'Ungültige Emailadresse',
licence: 'Nummer zu kurz(11 Zeichen)'
},
server: {
general: 'Allgemein',
error: {
invalid_json: 'JSON Daten sind ungültig',
no_auth_token: 'Nicht authorisiert, fehlender oder ungültiger Auth-Token',
jwt_parsing_error: 'Nicht authorisiert, Auth-Token konnte nicht gelesen werden',
unauthorized: 'Sie sind nicht befugt diese Handlung durchzuführen',
internal_server_error:
'Verdammt, Fehler auf unserer Seite, probieren Sie es nochmal, danach rufen Sie jemanden vom Verein an.',
not_possible: 'Vorgang nicht möglich.',
not_found: 'Konnte nicht gefunden werden.',
in_use: 'Ist in Benutzung',
undelivered_verification_mail:
'Registrierung erfolgreicht, leider konnte die Verifizierungs-E-Mail nicht versendet werden. Bitte wenden Sie sich an den Verein um Ihre Emailadresse zu bestätigen und Ihren Account zu aktivieren.'
},
validation: {
invalid: 'ungültig',
invalid_user_id: 'Nutzer ID ungültig',
invalid_subscription: 'Model nicht gefunden',
user_not_found: '{field} konnte nicht gefunden werden',
invalid_user_data: 'Nutzerdaten ungültig',
user_not_found_or_wrong_password: 'Existiert nicht oder falsches Passwort',
email_already_registered: 'Ein Mitglied wurde schon mit dieser Emailadresse erstellt.',
password_already_changed: 'Das Passwort wurde schon geändert.',
user_already_verified: 'Ihre Email Adresse wurde schon bestätigt.',
insecure: 'Unsicheres Passwort, versuchen Sie {message}',
longer: 'oder verwenden Sie ein längeres Passwort',
special: 'mehr Sonderzeichen einzufügen',
lowercase: 'Kleinbuchstaben zu verwenden',
uppercase: 'Großbuchstaben zu verwenden',
numbers: 'Zahlen zu verwenden',
alphanumunicode: 'beinhaltet nicht erlaubte Zeichen',
safe_content: 'I see what you did there! Do not cross this line!',
iban: 'Ungültig. Format: DE07123412341234123412',
bic: 'Ungültig. Format: BELADEBEXXX',
email: 'Format ungültig',
number: 'Ist keine Nummer',
euDriversLicence: 'Ist kein europäischer Führerschein',
lte: 'Ist zu groß/neu',
gt: 'Ist zu klein/alt',
required: 'Feld wird benötigt',
image: 'Dies ist kein Bild',
alphanum: 'beinhaltet ungültige Zeichen',
user_disabled: 'Benutzer ist deaktiviert',
duplicate: 'Schon vorhanden..',
alphaunicode: 'darf nur aus Buchstaben bestehen',
too_soon: 'zu früh'
}
},
licenceCategory: {
AM: 'Mopeds und leichte vierrädrige Kraftfahrzeuge (50ccm, max 45km/h)',
A1: 'Leichte Motorräder (125ccm)',
A2: 'Motorräder mit mittlerer Leistung (max 35kW)',
A: 'Motorräder',
B: 'Kraftfahrzeuge ≤ 3500 kg, ≤ 8 Sitzplätze',
C1: 'Mittelschwere Fahrzeuge -7500 kg',
C: 'Schwere Nutzfahrzeuge > 3500 kg',
D1: 'Kleinbusse 9-16 Sitzplätze',
D: 'Busse > 8 Sitzplätze',
BE: 'Fahrzeugklasse B mit Anhänger',
C1E: 'Fahrzeugklasse C1 mit Anhänger',
CE: 'Fahrzeugklasse C mit Anhänger',
D1E: 'Fahrzeugklasse D1 mit Anhänger',
DE: 'Fahrzeugklasse D mit Anhänger',
L: 'Land-, Forstwirtschaftsfahrzeuge, Stapler max 40km/h',
T: 'Land-, Forstwirtschaftsfahrzeuge, Stapler max 60km/h'
},
users: 'Mitglieder',
user: {
login: 'Nutzer Anmeldung',
edit: 'Nutzer bearbeiten',
create: 'Nutzer erstellen',
user: 'Nutzer',
member: 'Mitglied',
management: 'Mitgliederverwaltung',
id: 'Mitgliedsnr',
first_name: 'Vorname',
last_name: 'Nachname',
phone: 'Telefonnummer',
dateofbirth: 'Geburtstag',
email: 'Email',
membership: 'Mitgliedschaft',
bank_account: 'Kontodaten',
status: 'Status',
role: 'Nutzerrolle',
supporter: 'Sponsor',
opponent: 'Unfallgegner'
},
subscriptions: {
name: 'Modellname',
edit: 'Modell bearbeiten',
create: 'Modell erstellen',
subscription: 'Tarifmodell',
subscriptions: 'Tarifmodelle',
conditions: 'Bedingungen',
monthly_fee: 'Monatliche Gebühr',
hourly_rate: 'Stundensatz',
included_hours_per_year: 'Inkludierte Stunden pro Jahr',
included_hours_per_month: 'Inkludierte Stunden pro Monat'
},
car: {
car: 'Fahrzeug',
model: 'Modell',
brand: 'Marke',
licence_plate: 'Kennzeichen',
edit: 'Fahrzeug bearbeiten',
create: 'Fahrzeug hinzufügen',
damages: 'Schäden',
start_date: 'Anschaffungsdatum',
end_date: 'Leasingende',
leasing_rate: 'Leasingrate'
},
insurances: {
edit: 'Daten bearbeiten',
create: 'Versicherung erstellen'
},
loading: {
user_data: 'Lade Nutzerdaten',
subscription_data: 'Lade Modelldaten',
insurance_data: 'Lade Versicherungsdaten',
car_data: 'Lade Fahrzeugdaten',
please_wait: 'Bitte warten...',
updating: 'Aktualisiere...'
},
dialog: {
user_deletion: 'Soll der Nutzer {firstname} {lastname} wirklich gelöscht werden?',
subscription_deletion: 'Soll das Tarifmodell {name} wirklich gelöscht werden?',
car_deletion: 'Soll das Fahrzeug {name} wirklich gelöscht werden?',
insurance_deletion: 'Soll die Versicherung {name} wirklich gelöscht werden?',
damage_deletion: 'Soll der Schaden {name} wirklich gelöscht werden?',
backend_access: 'Soll {firstname} {lastname} Backend Zugriff gewährt werden?'
},
cancel: 'Abbrechen',
confirm: 'Bestätigen',
actions: 'Aktionen',
create: 'Hinzufügen',
edit: 'Bearbeiten',
delete: 'Löschen',
not_set: 'Nicht gesetzt',
noone: 'Niemand',
search: 'Suche:',
name: 'Name',
date: 'Datum',
price: 'Preis',
color: 'Farbe',
grant_backend_access: 'Backend Zugriff gewähren',
no_insurance: 'Keine Versicherung',
supporter: 'Sponsoren',
mandate_date_signed: 'Mandatserteilungsdatum',
licence_categories: 'Führerscheinklassen',
subscription: 'Mitgliedschatfsmodell',
licence: 'Führerschein',
licence_number: 'Führerscheinnummer',
insurance: 'Versicherung',
insurance_reference: 'Versicherungsnummer',
issued_date: 'Ausgabedatum',
month: 'Monat',
expiration_date: 'Ablaufdatum',
country: 'Land',
details: 'Details',
unknown: 'Unbekannt',
notes: 'Notizen',
address: 'Straße & Hausnummer',
city: 'Wohnort',
zip_code: 'PLZ',
forgot_password: 'Passwort vergessen?',
password: 'Passwort',
confirm_password: 'Passwort wiederholen',
password_changed: 'Passwort wurde erfolgreich geändert.',
change_password: 'Passwort ändern',
password_change_requested:
'Passwortänderungsanfrage wurde gesendet.. Bitte überprüfen Sie Ihr Postfach.',
company: 'Firma',
login: 'Anmeldung',
profile: 'Profil',
cars: 'Fahrzeuge',
status: 'Status',
start: 'Beginn',
end: 'Ende',
parent_member_id: 'Hauptmitgliedsnr.',
bank_account_holder: 'Kontoinhaber',
bank_name: 'Bank',
iban: 'IBAN',
bic: 'BIC',
mandate_reference: 'SEPA Mandat',
payments: 'Zahlungen',
add_new: 'Neu',
email_sent: 'Email wurde gesendet..',
verification: 'Verifikation',
// For payments section
payment: {
id: 'Zahlungs-Nr',
amount: 'Betrag',
date: 'Datum',
status: 'Status'
},
// For subscription statuses
subscriptionStatus: {
pending: 'Ausstehend',
completed: 'Abgeschlossen',
failed: 'Fehlgeschlagen',
cancelled: 'Storniert'
}
};

View File

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

View File

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

View File

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

View File

@@ -1,24 +1,23 @@
// @ts-nocheck
import { quintOut } from "svelte/easing";
import { crossfade } from "svelte/transition";
import { quintOut } from 'svelte/easing';
import { crossfade } from 'svelte/transition';
export const [send, receive] = crossfade({
duration: (d) => Math.sqrt(d * 200),
duration: (d) => Math.sqrt(d * 200),
// eslint-disable-next-line no-unused-vars
fallback(node, params) {
const style = getComputedStyle(node);
const transform = style.transform === "none" ? "" : style.transform;
// eslint-disable-next-line no-unused-vars
fallback(node, params) {
const style = getComputedStyle(node);
const transform = style.transform === 'none' ? '' : style.transform;
return {
duration: 600,
easing: quintOut,
css: (t) => `
return {
duration: 600,
easing: quintOut,
css: (t) => `
transform: ${transform} scale(${t});
opacity: ${t}
`,
};
},
`
};
}
});
/**
@@ -27,9 +26,9 @@ export const [send, receive] = crossfade({
* @param {string} email - The email to validate
*/
export const isValidEmail = (email) => {
const EMAIL_REGEX =
/[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/;
return EMAIL_REGEX.test(email.trim());
const EMAIL_REGEX =
/[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/;
return EMAIL_REGEX.test(email.trim());
};
/**
* Validates a strong password field
@@ -37,11 +36,9 @@ export const isValidEmail = (email) => {
* @param {string} password - The password to validate
*/
export const isValidPasswordStrong = (password) => {
const strongRegex = new RegExp(
"^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})"
);
const strongRegex = new RegExp('^(?=.*[a-z])(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%^&*])(?=.{8,})');
return strongRegex.test(password.trim());
return strongRegex.test(password.trim());
};
/**
* Validates a medium password field
@@ -49,11 +46,11 @@ export const isValidPasswordStrong = (password) => {
* @param {string} password - The password to validate
*/
export const isValidPasswordMedium = (password) => {
const mediumRegex = new RegExp(
"^(((?=.*[a-z])(?=.*[A-Z]))|((?=.*[a-z])(?=.*[0-9]))|((?=.*[A-Z])(?=.*[0-9])))(?=.{6,})"
);
const mediumRegex = new RegExp(
'^(((?=.*[a-z])(?=.*[A-Z]))|((?=.*[a-z])(?=.*[0-9]))|((?=.*[A-Z])(?=.*[0-9])))(?=.{6,})'
);
return mediumRegex.test(password.trim());
return mediumRegex.test(password.trim());
};
/**
@@ -63,143 +60,178 @@ export const isValidPasswordMedium = (password) => {
*/
export function isEmpty(obj) {
for (const _i in obj) {
return false;
}
return true;
}
export function toRFC3339(dateString) {
if (!dateString) dateString = "0001-01-01T00:00:00.000Z";
const date = new Date(dateString);
return date.toISOString();
}
export function fromRFC3339(dateString) {
if (!dateString) dateString = "0001-01-01T00:00:00.000Z";
const date = new Date(dateString);
return date.toISOString().split("T")[0];
for (const _i in obj) {
return false;
}
return true;
}
/**
*
* @param {App.Locals.User} user - The user object to format
* @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.date_of_birth) {
user.date_of_birth = fromRFC3339(user.date_of_birth);
}
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
);
}
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
* @param {App.Locals['user']} user - The user object to format
*/
export function userDatesToRFC3339(user) {
if (user.date_of_birth) {
user.date_of_birth = toRFC3339(user.date_of_birth);
}
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
);
}
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 {object} obj - The error object to format
* @returns {array} The formatted error object
* @param {string | Array<{field: string, key: string}> | Record<string, {key: string}>} obj - The error object to format
* @returns {Array<{
* field: string,
* key: string,
* id: number
* }> } Array of formatted error objects
*/
export function formatError(obj) {
const errors = [];
if (typeof obj === "object" && obj !== null) {
if (Array.isArray(obj)) {
obj.forEach((error) => {
errors.push({
field: error.field,
key: error.key,
id: Math.random() * 1000,
});
});
} else {
Object.keys(obj).forEach((field) => {
errors.push({
field: field,
key: obj[field].key,
id: Math.random() * 1000,
});
});
}
} else {
errors.push({
field: "general",
key: obj,
id: 0,
});
}
return errors;
const errors = [];
if (typeof obj === 'object') {
if (Array.isArray(obj)) {
obj.forEach((error) => {
errors.push({
field: error.field,
key: error.key,
id: Math.random() * 1000
});
});
} else {
Object.keys(obj).forEach((field) => {
errors.push({
field: field,
key: obj[field].key,
id: Math.random() * 1000
});
});
}
} else {
errors.push({
field: 'general',
key: obj,
id: 0
});
}
return errors;
}
/**
*
* @param {string | null} newToken - The new token for the cookie to set
* @param {import('RequestEvent<Partial<Record<string, string>>, string | null>')} event - The event object
* @param {import('@sveltejs/kit').Cookies } cookies - The event object
*/
export function refreshCookie(newToken, event) {
if (newToken) {
const match = newToken.match(/jwt=([^;]+)/);
if (match) {
if (event) {
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
});
} else {
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
});
}
}
}
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,11 +1,8 @@
import { BASE_API_URI } from "$lib/utils/constants";
import { refreshCookie } from "$lib/utils/helpers";
import { redirect } from "@sveltejs/kit";
/** @type {import('./$types').LayoutServerLoad} */
export async function load({ locals, cookies }) {
return {
user: locals.user,
licence_categories: locals.licence_categories,
subscriptions: locals.subscriptions,
};
export async function load({ locals }) {
return {
user: locals.user,
licence_categories: locals.licence_categories,
subscriptions: locals.subscriptions
};
}

View File

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

View File

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

View File

@@ -1,187 +1,146 @@
import { BASE_API_URI } from "$lib/utils/constants";
import { formatError, userDatesFromRFC3339 } from "$lib/utils/helpers";
import { fail, redirect } from "@sveltejs/kit";
import { toRFC3339 } from "$lib/utils/helpers";
import { BASE_API_URI } from '$lib/utils/constants';
import { formatError, userDatesFromRFC3339 } from '$lib/utils/helpers';
import { fail, redirect } from '@sveltejs/kit';
import { formDataToObject, processUserFormData } from '$lib/utils/processing';
import { base } from '$app/paths';
/**
* @typedef {Object} UpdateData
* @property {Partial<App.Locals['user']>} user
*/
/** @type {import('./$types').PageServerLoad} */
export async function load({ locals, params }) {
// redirect user if not logged in
if (!locals.user) {
throw redirect(302, `/auth/login?next=/auth/about/${params.id}`);
}
// redirect user if not logged in
if (!locals.user) {
throw redirect(302, `${base}/auth/login?next=${base}/auth/about/${params.id}`);
}
}
/** @type {import('./$types').Actions} */
export const actions = {
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current user
* @returns Error data or redirects user to the home page or the previous page
*/
updateUser: async ({ request, fetch, cookies, locals }) => {
let formData = await request.formData();
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current user
* @returns Error data or redirects user to the home page or the previous page
*/
updateUser: async ({ request, fetch, cookies, locals }) => {
let formData = await request.formData();
const licenceCategories = formData
.getAll("licence_categories[]")
.filter((value) => typeof value === "string")
.map((value) => {
try {
return JSON.parse(value);
} catch (e) {
console.error("Failed to parse licence category:", value);
return null;
}
})
.filter(Boolean);
const rawFormData = formDataToObject(formData);
/** @type {{object: Partial<App.Locals['user']>, confirm_password: string}} */
const rawData = {
object: /** @type {Partial<App.Locals['user']>} */ (rawFormData.object),
confirm_password: rawFormData.confirm_password
};
// confirm password matches and is not empty. Otherwise set password to empty string
if (
rawData.object.password &&
rawData.confirm_password &&
(rawData.object.password != rawData.confirm_password || rawData.object.password.trim() == '')
) {
rawData.object.password = '';
}
const processedData = processUserFormData(rawData.object);
/** @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
const cleanUpdateData = JSON.parse(
JSON.stringify(updateData),
(key, value) => (value !== null && value !== "" ? value : undefined)
);
console.dir(formData);
console.dir(cleanUpdateData);
const apiURL = `${BASE_API_URI}/backend/users/update/`;
// const isCreating = !processedData.user.id || processedData.user.id === 0;
// console.log('Is creating: ', isCreating);
const apiURL = `${BASE_API_URI}/auth/users/`;
/** @type {RequestInit} */
const requestUpdateOptions = {
method: "PATCH",
credentials: "include",
headers: {
"Content-Type": "application/json",
Cookie: `jwt=${cookies.get("jwt")}`,
},
body: JSON.stringify(cleanUpdateData),
};
const res = await fetch(apiURL, requestUpdateOptions);
/** @type {RequestInit} */
const requestUpdateOptions = {
method: 'PUT',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Cookie: `jwt=${cookies.get('jwt')}`
},
body: JSON.stringify(processedData)
};
const res = await fetch(apiURL, requestUpdateOptions);
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.errors);
return fail(400, { errors: errors });
}
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.errors);
return fail(400, { errors: errors });
}
const response = await res.json();
locals.user = response;
userDatesFromRFC3339(locals.user);
throw redirect(303, `/auth/about/${response.id}`);
},
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current user
* @returns Error data or redirects user to the home page or the previous page
*/
uploadImage: async ({ request, fetch, cookies }) => {
const formData = await request.formData();
const response = await res.json();
locals.user = response;
userDatesFromRFC3339(locals.user);
throw redirect(303, `${base}/auth/about/${response.id}`);
},
/** @type {RequestInit} */
const requestInitOptions = {
method: "POST",
headers: {
Cookie: `jwt=${cookies.get("jwt")}`,
},
body: formData,
};
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current user
* @returns Error data or redirects user to the home page or the previous page
*/
uploadImage: async ({ request, fetch, cookies }) => {
const formData = await request.formData();
const res = await fetch(`${BASE_API_URI}/file/upload/`, requestInitOptions);
/** @type {RequestInit} */
const requestInitOptions = {
method: 'POST',
headers: {
Cookie: `jwt=${cookies.get('jwt')}`
},
body: formData
};
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.errors);
return fail(400, { errors: errors });
}
const res = await fetch(`${BASE_API_URI}/file/upload/`, requestInitOptions);
const response = await res.json();
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.errors);
return fail(400, { errors: errors });
}
return {
success: true,
profile_picture: response[""],
};
},
const response = await res.json();
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current user
* @returns Error data or redirects user to the home page or the previous page
*/
deleteImage: async ({ request, fetch, cookies }) => {
const formData = await request.formData();
return {
success: true,
profile_picture: response['']
};
},
/** @type {RequestInit} */
const requestInitOptions = {
method: "DELETE",
headers: {
Cookie: `jwt=${cookies.get("jwt")}`,
},
body: formData,
};
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current user
* @returns Error data or redirects user to the home page or the previous page
*/
deleteImage: async ({ request, fetch, cookies }) => {
const formData = await request.formData();
const res = await fetch(`${BASE_API_URI}/file/delete/`, requestInitOptions);
/** @type {RequestInit} */
const requestInitOptions = {
method: 'DELETE',
headers: {
Cookie: `jwt=${cookies.get('jwt')}`
},
body: formData
};
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.errors);
return fail(400, { errors: errors });
}
const res = await fetch(`${BASE_API_URI}/file/delete/`, requestInitOptions);
return {
success: true,
profile_picture: "",
};
},
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.errors);
return fail(400, { errors: errors });
}
return {
success: true,
profile_picture: ''
};
}
};

View File

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

View File

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

View File

@@ -1,56 +1,69 @@
import { BASE_API_URI } from "$lib/utils/constants";
import { redirect } from "@sveltejs/kit";
import { userDatesFromRFC3339, refreshCookie } from "$lib/utils/helpers";
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 }) {
// if (locals.users) {
// return {
// users: locals.users,
// user: locals.user,
// };
// }
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 jwt = cookies.get("jwt");
try {
// Fetch user data, subscriptions, and licence categories in parallel
const response = await fetch(`${BASE_API_URI}/backend/users/all`, {
credentials: "include",
headers: {
Cookie: `jwt=${jwt}`,
},
});
if (!response.ok) {
// Clear the invalid JWT cookie
cookies.delete("jwt", { path: "/" });
throw redirect(302, "/auth/login?next=/");
}
// const data = await response.json();
/** @type {App.Locals['users']}*/
const users = usersData.users;
/** @type {App.Types['car'][]} */
const cars = carsData.cars;
const data = await response.json();
users.forEach((user) => {
userDatesFromRFC3339(user);
});
cars.forEach((car) => {
carDatesFromRFC3339(car);
});
// Check if the server sent a new token
const newToken = response.headers.get("Set-Cookie");
refreshCookie(newToken, null);
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: '/' });
/** @type {App.Locals['users']}*/
const users = data.users;
users.forEach((user) => {
userDatesFromRFC3339(user);
});
locals.users = users;
return {
subscriptions: locals.subscriptions,
licence_categories: locals.licence_categories,
users: locals.users,
user: locals.user,
};
} catch (error) {
console.error("Error fetching data:", error);
// In case of any error, clear the JWT cookie
cookies.delete("jwt", { path: "/" });
throw redirect(302, "/auth/login?next=/");
}
throw redirect(302, `${base}/auth/login?next=${base}/auth/admin/users/`);
}
}

View File

@@ -2,121 +2,334 @@
// - 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 } from "$lib/utils/constants";
import { formatError, userDatesFromRFC3339 } from "$lib/utils/helpers";
import { fail, redirect } from "@sveltejs/kit";
import { toRFC3339 } from "$lib/utils/helpers";
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, params }) {
// redirect user if not logged in
if (!locals.user) {
throw redirect(302, `/auth/login?next=/auth/users`);
}
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();
/**
*
* @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 licenceCategories = formData
.getAll("licence_categories[]")
.filter((value) => typeof value === "string")
.map((value) => {
try {
return JSON.parse(value);
} catch (e) {
console.error("Failed to parse licence category:", value);
return null;
}
})
.filter(Boolean);
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);
/** @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
const cleanUpdateData = JSON.parse(
JSON.stringify(updateData),
(key, value) => (value !== null && value !== "" ? value : undefined)
);
console.dir(formData);
console.dir(cleanUpdateData);
const apiURL = `${BASE_API_URI}/backend/users/update/`;
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 requestUpdateOptions = {
method: "PATCH",
credentials: "include",
headers: {
"Content-Type": "application/json",
Cookie: `jwt=${cookies.get("jwt")}`,
},
body: JSON.stringify(cleanUpdateData),
};
const res = await fetch(apiURL, requestUpdateOptions);
/** @type {RequestInit} */
const requestOptions = {
method: isCreating ? 'POST' : 'PUT',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Cookie: `jwt=${cookies.get('jwt')}`
},
body: JSON.stringify(user)
};
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.errors);
return fail(400, { errors: errors });
}
const res = await fetch(apiURL, requestOptions);
const response = await res.json();
locals.user = response;
userDatesFromRFC3339(locals.user);
throw redirect(303, `/auth/about/${response.id}`);
},
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

@@ -1,92 +1,916 @@
<!-- - Create a table or list view of all users.
- Implement a search or filter functionality.
- Add a modal component for editing user details (reuse the modal from about/[id]). -->
<script>
import { onMount } from "svelte";
import Modal from "$lib/components/Modal.svelte";
import UserEditForm from "$lib/components/UserEditForm.svelte";
import { t } from "svelte-i18n";
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';
import { page } from "$app/stores";
/** @type {import('./$types').ActionData} */
export let form;
/** @type {import('./$types').ActionData} */
export let form;
$: ({
user = [],
users = [],
cars = [],
licence_categories = [],
subscriptions = [],
payments = []
} = $page.data);
$: ({ user, users, licence_categories, subscriptions } = $page.data);
let activeSection = 'members';
/** @type(App.Locals['user'] | null) */
let selectedUser = null;
let showModal = false;
/** @type{App.Types['car'] | App.Types['subscription'] | App.Locals['user'] | null} */
let selected = null;
/**
* Opens the edit modal for the selected user.
* @param {App.Locals['user']} user The user to edit.
*/
const openEditModal = (user) => {
selectedUser = user;
showModal = true;
};
let searchTerm = '';
/**
* Opens the delete modal for the selected user.
* @param {App.Locals['user']} user The user to edit.
*/
const openDelete = (user) => {};
$: 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;
const close = () => {
showModal = false;
selectedUser = null;
if (form) {
form.errors = undefined;
}
};
$: 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="admin-users-page">
<h1>{$t("user.management")}</h1>
<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>
<div class="search-filter" />
<!-- 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}
<table class="user-table">
<thead>
<tr>
<th>{$t("user.id")}</th>
<th>{$t("name")}</th>
<th>{$t("email")}</th>
<th>{$t("status")}</th>
<th>{$t("actions")}</th>
</tr>
</thead>
<tbody>
{#each users as user}
<tr>
<td>{user.id}</td>
<td>{user.first_name} {user.last_name}</td>
<td>{user.email}</td>
<td>{$t("userStatus." + user.status)}</td>
<td>
<button on:click={() => openEditModal(user)}>{$t("edit")}</button>
<button on:click={() => openDelete(user)}>{$t("delete")}</button>
</td>
</tr>{/each}
</tbody>
</table>
<div class="pagination" />
{#if showModal}
<Modal on:close={close}>
<UserEditForm
{form}
user={selectedUser}
{subscriptions}
{licence_categories}
on:cancel={close}
/>
</Modal>
{/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,75 +1,77 @@
import { BASE_API_URI } from "$lib/utils/constants";
import { formatError } from "$lib/utils/helpers";
import { fail, redirect } from "@sveltejs/kit";
import { base } from '$app/paths';
import { BASE_API_URI } from '$lib/utils/constants';
import { formatError } from '$lib/utils/helpers';
import { fail, redirect } from '@sveltejs/kit';
/** @type {import('./$types').PageServerLoad} */
export async function load({ locals }) {
// redirect user if logged in
if (locals.user) {
throw redirect(302, "/");
}
// redirect user if logged in
console.log('loading login page');
if (locals.user) {
console.log('user is logged in');
throw redirect(302, `${base}/auth/about/${locals.user.id}`);
}
}
/** @type {import('./$types').Actions} */
export const actions = {
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @returns Error data or redirects user to the home page or the previous page
*/
login: async ({ request, fetch, cookies }) => {
const data = await request.formData();
const email = String(data.get("email"));
const password = String(data.get("password"));
const next = String(data.get("next"));
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @returns Error data or redirects user to the home page or the previous page
*/
login: async ({ request, fetch, cookies }) => {
console.log('login action called');
const data = await request.formData();
const email = String(data.get('email'));
const password = String(data.get('password'));
const next = String(data.get('next'));
/** @type {RequestInit} */
const requestInitOptions = {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: email,
password: password
})
};
console.log('API call url:', `${BASE_API_URI}/users/login`);
const res = await fetch(`${BASE_API_URI}/users/login`, requestInitOptions);
console.log('Login response status:', res.status);
console.log('Login response headers:', Object.fromEntries(res.headers));
/** @type {RequestInit} */
const requestInitOptions = {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email: email,
password: password,
}),
};
if (!res.ok) {
const errorData = await res.json();
const errors = formatError(errorData.errors);
return fail(res.status, { errors });
}
const res = await fetch(`${BASE_API_URI}/users/login/`, requestInitOptions);
const responseBody = await res.json();
console.log('Login response body:', responseBody);
console.log("Login response status:", res.status);
console.log("Login response headers:", Object.fromEntries(res.headers));
// Extract the JWT from the response headers
const setCookieHeader = res.headers.get('set-cookie');
if (setCookieHeader) {
const jwtMatch = setCookieHeader.match(/jwt=([^;]+)/);
if (jwtMatch) {
const jwtValue = jwtMatch[1];
// Set the cookie for the client
cookies.set('jwt', jwtValue, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // Secure in production
sameSite: 'lax',
maxAge: 5 * 24 * 60 * 60 // 5 days in seconds
});
}
}
if (!res.ok) {
const errorData = await res.json();
const errors = formatError(errorData.errors);
return fail(res.status, { errors });
}
const responseBody = await res.json();
console.log("Login response body:", responseBody);
// Extract the JWT from the response headers
const setCookieHeader = res.headers.get("set-cookie");
if (setCookieHeader) {
const jwtMatch = setCookieHeader.match(/jwt=([^;]+)/);
if (jwtMatch) {
const jwtValue = jwtMatch[1];
// Set the cookie for the client
cookies.set("jwt", jwtValue, {
path: "/",
httpOnly: true,
secure: process.env.NODE_ENV === "production", // Secure in production
sameSite: "lax",
maxAge: 5 * 24 * 60 * 60, // 5 days in seconds
});
}
}
console.log("Redirecting to:", next || "/");
throw redirect(303, next || "/");
},
console.log('Redirecting to:', next || `${base}/auth/about/${responseBody.user_id}`);
throw redirect(303, next || `${base}/auth/about/${responseBody.user_id}`);
}
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,426 +0,0 @@
/*!
* Bootstrap Reboot v5.0.2 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
@media (prefers-reduced-motion: no-preference) {
:root {
scroll-behavior: smooth;
}
}
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", "Liberation Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: 1rem;
font-weight: 400;
line-height: 1.5;
color: #212529;
background-color: #fff;
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}
hr {
margin: 1rem 0;
color: inherit;
background-color: currentColor;
border: 0;
opacity: 0.25;
}
hr:not([size]) {
height: 1px;
}
h6, h5, h4, h3, h2, h1 {
margin-top: 0;
margin-bottom: 0.5rem;
font-weight: 500;
line-height: 1.2;
}
h1 {
font-size: calc(1.375rem + 1.5vw);
}
@media (min-width: 1200px) {
h1 {
font-size: 2.5rem;
}
}
h2 {
font-size: calc(1.325rem + 0.9vw);
}
@media (min-width: 1200px) {
h2 {
font-size: 2rem;
}
}
h3 {
font-size: calc(1.3rem + 0.6vw);
}
@media (min-width: 1200px) {
h3 {
font-size: 1.75rem;
}
}
h4 {
font-size: calc(1.275rem + 0.3vw);
}
@media (min-width: 1200px) {
h4 {
font-size: 1.5rem;
}
}
h5 {
font-size: 1.25rem;
}
h6 {
font-size: 1rem;
}
p {
margin-top: 0;
margin-bottom: 1rem;
}
abbr[title],
abbr[data-bs-original-title] {
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
cursor: help;
-webkit-text-decoration-skip-ink: none;
text-decoration-skip-ink: none;
}
address {
margin-bottom: 1rem;
font-style: normal;
line-height: inherit;
}
ol,
ul {
padding-left: 2rem;
}
ol,
ul,
dl {
margin-top: 0;
margin-bottom: 1rem;
}
ol ol,
ul ul,
ol ul,
ul ol {
margin-bottom: 0;
}
dt {
font-weight: 700;
}
dd {
margin-bottom: 0.5rem;
margin-left: 0;
}
blockquote {
margin: 0 0 1rem;
}
b,
strong {
font-weight: bolder;
}
small {
font-size: 0.875em;
}
mark {
padding: 0.2em;
background-color: #fcf8e3;
}
sub,
sup {
position: relative;
font-size: 0.75em;
line-height: 0;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
a {
color: #0d6efd;
text-decoration: underline;
}
a:hover {
color: #0a58ca;
}
a:not([href]):not([class]), a:not([href]):not([class]):hover {
color: inherit;
text-decoration: none;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 1em;
direction: ltr /* rtl:ignore */;
unicode-bidi: bidi-override;
}
pre {
display: block;
margin-top: 0;
margin-bottom: 1rem;
overflow: auto;
font-size: 0.875em;
}
pre code {
font-size: inherit;
color: inherit;
word-break: normal;
}
code {
font-size: 0.875em;
color: #d63384;
word-wrap: break-word;
}
a > code {
color: inherit;
}
kbd {
padding: 0.2rem 0.4rem;
font-size: 0.875em;
color: #fff;
background-color: #212529;
border-radius: 0.2rem;
}
kbd kbd {
padding: 0;
font-size: 1em;
font-weight: 700;
}
figure {
margin: 0 0 1rem;
}
img,
svg {
vertical-align: middle;
}
table {
caption-side: bottom;
border-collapse: collapse;
}
caption {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
color: #6c757d;
text-align: left;
}
th {
text-align: inherit;
text-align: -webkit-match-parent;
}
thead,
tbody,
tfoot,
tr,
td,
th {
border-color: inherit;
border-style: solid;
border-width: 0;
}
label {
display: inline-block;
}
button {
border-radius: 0;
}
button:focus:not(:focus-visible) {
outline: 0;
}
input,
button,
select,
optgroup,
textarea {
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
}
button,
select {
text-transform: none;
}
[role=button] {
cursor: pointer;
}
select {
word-wrap: normal;
}
select:disabled {
opacity: 1;
}
[list]::-webkit-calendar-picker-indicator {
display: none;
}
button,
[type=button],
[type=reset],
[type=submit] {
-webkit-appearance: button;
}
button:not(:disabled),
[type=button]:not(:disabled),
[type=reset]:not(:disabled),
[type=submit]:not(:disabled) {
cursor: pointer;
}
::-moz-focus-inner {
padding: 0;
border-style: none;
}
textarea {
resize: vertical;
}
fieldset {
min-width: 0;
padding: 0;
margin: 0;
border: 0;
}
legend {
float: left;
width: 100%;
padding: 0;
margin-bottom: 0.5rem;
font-size: calc(1.275rem + 0.3vw);
line-height: inherit;
}
@media (min-width: 1200px) {
legend {
font-size: 1.5rem;
}
}
legend + * {
clear: left;
}
::-webkit-datetime-edit-fields-wrapper,
::-webkit-datetime-edit-text,
::-webkit-datetime-edit-minute,
::-webkit-datetime-edit-hour-field,
::-webkit-datetime-edit-day-field,
::-webkit-datetime-edit-month-field,
::-webkit-datetime-edit-year-field {
padding: 0;
}
::-webkit-inner-spin-button {
height: auto;
}
[type=search] {
outline-offset: -2px;
-webkit-appearance: textfield;
}
/* rtl:raw:
[type="tel"],
[type="url"],
[type="email"],
[type="number"] {
direction: ltr;
}
*/
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-color-swatch-wrapper {
padding: 0;
}
::file-selector-button {
font: inherit;
}
::-webkit-file-upload-button {
font: inherit;
-webkit-appearance: button;
}
output {
display: inline-block;
}
iframe {
border: 0;
}
summary {
display: list-item;
cursor: pointer;
}
progress {
vertical-align: baseline;
}
[hidden] {
display: none !important;
}
/*# sourceMappingURL=bootstrap-reboot.css.map */

File diff suppressed because one or more lines are too long

View File

@@ -1,8 +0,0 @@
/*!
* Bootstrap Reboot v5.0.2 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors
* Copyright 2011-2021 Twitter, Inc.
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* Forked from Normalize.css, licensed MIT (https://github.com/necolas/normalize.css/blob/master/LICENSE.md)
*/*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;background-color:#fff;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;background-color:currentColor;border:0;opacity:.25}hr:not([size]){height:1px}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2}h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){h1{font-size:2.5rem}}h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){h2{font-size:2rem}}h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){h3{font-size:1.75rem}}h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){h4{font-size:1.5rem}}h5{font-size:1.25rem}h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[data-bs-original-title],abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:.875em}mark{padding:.2em;background-color:#fcf8e3}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#0d6efd;text-decoration:underline}a:hover{color:#0a58ca}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em;direction:ltr;unicode-bidi:bidi-override}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:#d63384;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:.875em;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:1em;font-weight:700}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:#6c757d;text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]::-webkit-calendar-picker-indicator{display:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:textfield}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::file-selector-button{font:inherit}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}
/*# sourceMappingURL=bootstrap-reboot.min.css.map */

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
"GoMembership/internal/config"
"GoMembership/internal/constants"
"GoMembership/internal/database"
"GoMembership/internal/models"
"GoMembership/internal/repositories"
@@ -44,12 +45,14 @@ type loginInput struct {
}
var (
Uc *UserController
Mc *MembershipController
Cc *ContactController
Uc *UserController
Mc *MembershipController
Cc *ContactController
AdminCookie *http.Cookie
MemberCookie *http.Cookie
)
func TestSuite(t *testing.T) {
func TestMain(t *testing.T) {
_ = deleteTestDB("test.db")
cwd, err := os.Getwd()
@@ -84,7 +87,8 @@ func TestSuite(t *testing.T) {
log.Fatalf("Error setting environment variable: %v", err)
}
config.LoadConfig()
if err := database.Open("test.db", config.Recipients.AdminEmail); err != nil {
db, err := database.Open("test.db", config.Recipients.AdminEmail, true)
if err != nil {
log.Fatalf("Failed to create DB: %#v", err)
}
utils.SMTPStart(Host, Port)
@@ -96,17 +100,16 @@ func TestSuite(t *testing.T) {
bankAccountService := &services.BankAccountService{Repo: bankAccountRepo}
var membershipRepo repositories.MembershipRepositoryInterface = &repositories.MembershipRepository{}
var subscriptionRepo repositories.SubscriptionModelsRepositoryInterface = &repositories.SubscriptionModelsRepository{}
var subscriptionRepo repositories.SubscriptionsRepositoryInterface = &repositories.SubscriptionsRepository{}
membershipService := &services.MembershipService{Repo: membershipRepo, SubscriptionRepo: subscriptionRepo}
var licenceRepo repositories.LicenceInterface = &repositories.LicenceRepository{}
var userRepo repositories.UserRepositoryInterface = &repositories.UserRepository{}
userService := &services.UserService{Repo: userRepo, Licences: licenceRepo}
userService := &services.UserService{DB: db, Licences: licenceRepo}
licenceService := &services.LicenceService{Repo: licenceRepo}
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}
if err := initSubscriptionPlans(); err != nil {
@@ -116,11 +119,36 @@ func TestSuite(t *testing.T) {
if err := initLicenceCategories(); err != nil {
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) {
testUserController(t)
})
t.Run("Password_Controller", func(t *testing.T) {
})
t.Run("SQL_Injection", func(t *testing.T) {
testSQLInjectionAttempt(t)
})
@@ -136,7 +164,6 @@ func TestSuite(t *testing.T) {
t.Run("XSSAttempt", func(t *testing.T) {
testXSSAttempt(t)
})
if err := utils.SMTPStop(); err != nil {
log.Fatalf("Failed to stop SMTP Mockup Server: %#v", err)
}
@@ -176,7 +203,7 @@ func initLicenceCategories() error {
}
func initSubscriptionPlans() error {
subscriptions := []models.SubscriptionModel{
subscriptions := []models.Subscription{
{
Name: "Basic",
Details: "Test Plan",
@@ -248,23 +275,41 @@ func GetMockedFormContext(formData url.Values, url string) (*gin.Context, *httpt
func getBaseUser() models.User {
return models.User{
DateOfBirth: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC),
FirstName: "John",
LastName: "Doe",
Email: "john.doe@example.com",
Address: "Pablo Escobar Str. 4",
ZipCode: "25474",
City: "Hasloh",
Phone: "01738484993",
BankAccount: models.BankAccount{IBAN: "DE89370400440532013000"},
Membership: models.Membership{SubscriptionModel: models.SubscriptionModel{Name: "Basic"}},
Licence: nil,
ProfilePicture: "",
Password: "password123",
Company: "",
DateOfBirth: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC),
FirstName: "John",
LastName: "Doe",
Email: "john.doe@example.com",
Address: "Pablo Escobar Str. 4",
ZipCode: "25474",
City: "Hasloh",
Phone: "01738484993",
BankAccount: &models.BankAccount{IBAN: "DE89370400440532013000"},
Membership: &models.Membership{Subscription: models.Subscription{Name: "Basic"}},
Licence: nil,
Password: "passw@#$#%$!-ord123",
Company: "",
RoleID: 1,
}
}
func getBaseSupporter() models.User {
return models.User{
DateOfBirth: time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC),
FirstName: "John",
LastName: "Rich",
Email: "john.supporter@example.com",
Address: "Pablo Escobar Str. 4",
ZipCode: "25474",
City: "Hasloh",
Phone: "01738484993",
BankAccount: &models.BankAccount{IBAN: "DE89370400440532013000"},
Membership: &models.Membership{Subscription: models.Subscription{Name: "Basic"}},
Licence: nil,
Password: "passw@#$#%$!-ord123",
Company: "",
RoleID: 0,
}
}
func deleteTestDB(dbPath string) error {
err := os.Remove(dbPath)
if err != nil {

View File

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

View File

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

View File

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

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