Compare commits

..

103 Commits

Author SHA1 Message Date
theis.gaedigk 55c44cc639 Merge branch 'dev_demo' into debian12Demo 2026-04-18 14:59:57 +02:00
theis.gaedigk 34f133f94c Merge branch 'dev' into demoDev 2026-04-18 14:58:58 +02:00
theis.gaedigk 4155982aac edited version infos 2026-04-18 14:57:51 +02:00
theis.gaedigk 59e05e5c4c changed license 2026-04-15 19:37:59 +02:00
theis.gaedigk 2facdab011 added icon 2026-03-26 23:03:19 +01:00
theis.gaedigk 04db0bd7e0 edited icon 2026-03-26 23:01:05 +01:00
theis.gaedigk 40dd34bcac changed icon 2026-03-26 22:25:46 +01:00
theis.gaedigk 939d3e89c5 added footage and updated gitignore 2026-03-17 20:35:42 +01:00
theis.gaedigk a355c42964 added license 2026-02-23 21:37:54 +01:00
theis.gaedigk a3df8172e2 Merge branch 'demoDev' into debian12Demo 2026-02-22 23:50:32 +01:00
theis.gaedigk 98834a9270 Merge branch 'dev' into demoDev 2026-02-22 23:50:00 +01:00
theis.gaedigk c8a230979f changed version info 2026-02-22 23:45:38 +01:00
theis.gaedigk 581cd4a1fd changed link of help wiki 2026-02-22 23:41:25 +01:00
theis.gaedigk c53e6e095a updated api documentation 2026-02-22 23:33:30 +01:00
theis.gaedigk eaa325668c Implemented page header and added note column 2026-02-22 23:21:33 +01:00
theis.gaedigk c00f720af4 Merge branch 'demoDev' into debian12Demo 2026-02-20 16:34:21 +01:00
theis.gaedigk 8d04465705 Merge branch 'dev' into demoDev 2026-02-20 16:33:54 +01:00
theis.gaedigk 1fa8b4a9a7 refactor: clean up layout components and improve footer styling 2026-02-20 16:30:20 +01:00
theis.gaedigk 3ba3c1c0cb improved error logging for the api route to return or take loans 2026-02-20 16:22:13 +01:00
theis.gaedigk ee54d51f8b enhanced loan management: added note field to loan creation and email templates 2026-02-20 12:14:56 +01:00
theis.gaedigk a8dab549af fixed bug: cannot return loan 2026-02-20 12:02:33 +01:00
theis.gaedigk 06976f7972 added pasword input to admin panel 2026-02-14 19:01:59 +01:00
theis.gaedigk 06cb298a38 Merge branch 'demoDev' into debian12Demo 2026-02-09 15:55:52 +01:00
theis.gaedigk 8589971dc8 added scheme to demo branch 2026-02-09 15:55:27 +01:00
theis.gaedigk 6ec8e19737 Merge branch 'dev' into demoDev 2026-02-09 15:51:25 +01:00
theis.gaedigk 977a6c1b16 deltedt mock data because its too old 2026-02-09 15:51:00 +01:00
theis.gaedigk 38c647c62f Fixed bug/issue: #13 2026-02-09 15:49:51 +01:00
theis.gaedigk 757b316b49 fixed translation bug 2026-02-09 13:42:19 +01:00
theis.gaedigk d05e9ab3ee deleted unused changelog 2026-02-09 13:42:06 +01:00
theis.gaedigk d29c793b6b Merge branch 'demoDev' into debian12Demo 2026-02-07 17:50:53 +01:00
theis.gaedigk 9f44a4796d Merge branch 'dev' into demoDev 2026-02-07 17:50:34 +01:00
theis.gaedigk 3f59ed6951 removed line 2026-02-07 17:48:23 +01:00
theis.gaedigk c97cc8b538 Merge branch 'demoDev' into debian12Demo 2026-02-07 17:44:12 +01:00
theis.gaedigk dc0a68f7f1 fixed version info 2026-02-07 17:42:21 +01:00
theis.gaedigk fe3a06e5ce Merge branch 'dev' into demoDev 2026-02-07 17:41:57 +01:00
theis.gaedigk 6efb0fee80 updated modules 2026-02-07 17:39:31 +01:00
theis.gaedigk 2e98fa50de refactor: update contact page message description and improve email logging 2026-02-07 17:33:16 +01:00
theis.gaedigk 776fab749d Merge branch 'demoDev' into debian12Demo 2026-02-04 13:48:28 +01:00
theis.gaedigk 179f5686d1 Merge branch 'dev' into demoDev 2026-02-04 13:47:33 +01:00
theis.gaedigk 7221ee1843 fixed message bug 2026-02-04 13:46:52 +01:00
theis.gaedigk 83b43f4c83 fixed bug again 2026-02-01 16:30:18 +01:00
theis.gaedigk 5d9cee63ab fixed bug: cannot start container 2026-02-01 16:28:47 +01:00
theis.gaedigk 0b203d838c added sth for demo distr 2026-02-01 16:26:58 +01:00
theis.gaedigk ae1888fe90 added secret user 2026-02-01 16:20:00 +01:00
theis.gaedigk f1c02910e6 changed docker config and other 2026-02-01 16:03:16 +01:00
theis.gaedigk d33b288956 chengd nginx config 2026-02-01 16:00:12 +01:00
theis.gaedigk 5e2a426401 edited docker config 2026-02-01 15:50:31 +01:00
theis.gaedigk 022aa669e8 Merge branch 'demoDev' into debian12Demo 2026-02-01 15:49:05 +01:00
theis.gaedigk 28373e0231 changed version info 2026-02-01 15:47:51 +01:00
theis.gaedigk ae0cb5af81 changed website title 2026-01-28 18:32:50 +01:00
theis.gaedigk 2f3583ccd0 Merge branch 'dev' into debian12 2026-01-28 18:26:08 +01:00
theis.gaedigk 80f38fcd3d fixed Admin Panel Bug: cannot change Password 2026-01-28 18:25:56 +01:00
theis.gaedigk 9da72cc5bf Merge branch 'dev' into debian12 2026-01-28 13:06:19 +01:00
theis.gaedigk 70f3d1fdcc added: User can return loan from web panel 2026-01-28 13:06:03 +01:00
theis.gaedigk c633627b7c Merge branch 'dev' into debian12 2026-01-28 12:44:25 +01:00
theis.gaedigk 4b08a574d8 fixed Bug: redirecting
Also removed irrelavant console.logs
2026-01-28 12:43:58 +01:00
theis.gaedigk 5259c41b13 Merge branch 'dev' into debian12 2026-01-27 21:29:08 +01:00
theis.gaedigk 5aa8a32020 d 2026-01-27 21:26:31 +01:00
theis.gaedigk b58a04b030 edited contact page and header 2026-01-27 21:26:18 +01:00
theis.gaedigk e1615f9345 added functional mailer 2026-01-27 21:11:01 +01:00
theis.gaedigk ce760eb721 edited frontend for sending messages 2026-01-27 20:59:02 +01:00
theis.gaedigk 3d9e3814fe edited docker 2026-01-27 10:33:32 +01:00
theis.gaedigk 109cd7660a added contact page 2026-01-26 16:14:48 +01:00
theis.gaedigk b44edb2b1d chnaged config 2026-01-16 17:17:15 +01:00
theis.gaedigk a72fabc0a0 Merge branch 'dev' into debian12 2026-01-16 17:11:30 +01:00
theis.gaedigk 727bd832dc edited docs 2026-01-16 17:10:47 +01:00
theis.gaedigk 3b93b1fa23 secured admin frontend as well 2026-01-16 17:09:11 +01:00
theis.gaedigk 9963731b10 secured backend -> made backend internal accessable 2026-01-16 17:07:56 +01:00
theis.gaedigk 5546401aa4 refactored dialogue component 2026-01-07 15:44:44 +01:00
theis.gaedigk 2f405539fb changed translation 2026-01-07 15:32:06 +01:00
theis.gaedigk 1406f28f86 Merge branch 'dev' into debian12 2026-01-07 15:06:51 +01:00
theis.gaedigk c803e42a76 fixed docs api key example 2026-01-07 15:06:28 +01:00
theis.gaedigk 76c0e6a64b added 404 2025-12-05 14:44:09 +01:00
theis.gaedigk ebda6424c7 fixed design of item table in the admin panel 2025-12-05 10:17:46 +01:00
theis.gaedigk 38d1091e9b Merge branch 'dev' into debian12 2025-11-30 21:23:22 +01:00
theis.gaedigk f82efecb8c edited docker config 2025-11-30 21:21:21 +01:00
theis.gaedigk 1f12bc8839 t 2025-11-30 21:17:36 +01:00
theis.gaedigk f19750f6f3 edited port config 2025-11-30 21:12:14 +01:00
theis.gaedigk 808b3fd5c4 Merge branch 'dev' into debian12 2025-11-30 21:07:32 +01:00
theis.gaedigk e362515eff edited gitignore 2025-11-29 14:56:49 +01:00
theis.gaedigk 31960d1ff8 fixed footer message 2025-11-25 18:02:05 +01:00
theis.gaedigk 0891598eb9 changed version info 2025-11-25 17:30:56 +01:00
theis.gaedigk 39ff02f2e7 Merge branch 'dev' into debian12 2025-11-25 17:11:27 +01:00
theis.gaedigk 3bf5560834 edited docs 2025-11-25 17:09:22 +01:00
theis.gaedigk 4c60fea4c4 fixed bug: mailer did not send email 2025-11-25 16:57:20 +01:00
theis.gaedigk 0577a63205 deleted xls 2025-11-25 16:15:08 +01:00
theis.gaedigk fd2ccaa747 feat: add door_key field to items and update related logic in forms and database 2025-11-24 17:12:37 +01:00
theis.gaedigk df6b5eac59 fixed bug and edited version 2025-11-24 16:55:27 +01:00
theis.gaedigk d64489aed4 edited scheme but still not working 2025-11-24 16:05:56 +01:00
theis.gaedigk cc67fb4f85 changed version info 2025-11-24 15:35:03 +01:00
theis.gaedigk 75ff4aadc1 fixed color bug 2025-11-24 14:16:55 +01:00
theis.gaedigk 6f998d07c1 Merge branch 'dev' into debian12 2025-11-23 21:52:34 +01:00
theis.gaedigk cb6b5858e5 refactor: rename lockerNumber to safe_nr and update related logic 2025-11-23 21:52:12 +01:00
theis.gaedigk f2bb326040 Merge branch 'dev' into debian12 2025-11-23 21:40:11 +01:00
theis.gaedigk 85e6d7fe00 fixed bugs 2025-11-23 21:39:18 +01:00
theis.gaedigk 4b9f55268c updated scheme 2025-11-23 21:25:08 +01:00
theis.gaedigk 8c701db900 changed ports 2025-11-23 21:11:23 +01:00
theis.gaedigk d1664338a6 add networks configuration for frontend and backend services in docker-compose 2025-11-23 21:06:12 +01:00
theis.gaedigk 1a2624cd9e again 2025-11-23 20:34:19 +01:00
theis.gaedigk a138190cc6 fixed bugs 2025-11-23 20:32:14 +01:00
theis.gaedigk 993e0cd74b fixed bugs 2025-11-23 20:29:31 +01:00
theis.gaedigk 90ca266793 changed docker config 2025-11-23 20:26:49 +01:00
theis.gaedigk dab004a7b6 changed docker config 2025-11-23 20:26:27 +01:00
59 changed files with 1277 additions and 1150 deletions
+9
View File
@@ -113,3 +113,12 @@ secrets/
keys/ keys/
ToDo.txt ToDo.txt
# only in development branch
next-env.d.ts
# psd files from footage
footage/*.psd
icon/
+81 -249
View File
@@ -1,345 +1,177 @@
# Backend API (V2) Documentation # Borrow System API Documentation
This document describes the current backend API routes and their real response shapes, based on the code in `backendV2`. ## Overview
--- The Borrow System API provides endpoints for managing items, loans, and door access for a borrowing/locker system. All endpoints require authentication via an 8-digit API key passed as a URL parameter.
## Base URLs
- Frontend: `https://insta.the1s.de`
- Backend: `https://backend.insta.the1s.de`
- Base path: `https://backend.insta.the1s.de/api`
Service status: `https://status.the1s.de`
---
## Authentication ## Authentication
All **protected** endpoints require an API key as a path parameter `:key`. All requests must include a valid API key in the URL path as the `:key` parameter. API keys are 8-digit numeric strings.
Rules for `:key`: ## Endpoints
- Exactly 8 characters The Base URL for all endpoints is: `https://insta.the1s.de/backend/api`
- Digits only (`^[0-9]{8}$`)
Example: ### Get All Items
```http `GET /items/:key`
GET /api/items/12345678
```
On missing / invalid key: Returns all items in the system.
- Status: `401 Unauthorized` **Response 200:**
- Body (exact message depends on `authenticate` in `backendV2/services/authentication.js`)
Auth-related modules:
- `backendV2/services/authentication.js`
- `backendV2/services/database.js`
Route handlers:
- `backendV2/routes/api/api.route.js`
- `backendV2/routes/api/api.database.js`
---
## Endpoints (Overview)
1. **Public**
- `GET /api/all-items` List all items (no auth; from original docs)
2. **Items (authenticated)**
- `GET /api/items/:key` List all items
- `POST /api/change-state/:key/:itemId/:state` Toggle item safe state
3. **Loans (authenticated)**
- `GET /api/get-loan-by-code/:key/:loan_code` Get loan by code
- `POST /api/set-take-date/:key/:loan_code` Set “take” date and mark items as out
- `POST /api/set-return-date/:key/:loan_code` Set “return” date and mark items as returned
---
## 1) Items
### 1.1 Get all items
**GET** `/api/items/:key`
Returns all items wrapped in a `data` property.
- Handler: `getItemsFromDatabaseV2` in `api.database.js`
- SQL: `SELECT * FROM items;`
#### Example request
```http
GET https://backend.insta.the1s.de/api/items/12345678
```
#### Successful response
```json ```json
{ {
"data": [ "data": [
{ {
"id": 1, "id": 1,
"item_name": "DJI 1er Mikro", "item_name": "Laptop",
"can_borrow_role": 4, "can_borrow_role": 1,
"in_safe": 1, "in_safe": true,
"safe_nr": "01", "safe_nr": 3,
"entry_created_at": "2025-08-19T22:02:16.000Z", "door_key": 101,
"entry_updated_at": "2025-08-19T22:02:16.000Z", "last_borrowed_person": "jdoe",
"last_borrowed_person": "alice",
"currently_borrowing": null "currently_borrowing": null
} }
] ]
} }
``` ```
#### Error response **Response 500:**
```json ```json
{ "message": "Failed to fetch items" } { "message": "Failed to fetch items" }
``` ```
#### Status codes
- `200 OK` success, `data` is an array (possibly empty)
- `401 Unauthorized` invalid / missing key
- `500 Internal Server Error` database error or `success: false` from DB layer
--- ---
### 2.2 Toggle item safe state ### Change Item Safe State
**POST** `/api/change-state/:key/:itemId` `POST /change-state/:key/:itemId`
> You do not need this endpoint to set the states of the items when the items are taken out or returned. When you take or return a loan, the item states are set automatically by the loan endpoints. This endpoint is only for manually toggling the `inSafe` state of an item. Toggles the `in_safe` boolean state of an item.
Path parameters: **URL Parameters:**
- `:key` API key (8 digits) - **key** - API key
- `:itemId` numeric `id` of the item - **itemId** - The item's ID
Handler in `api.route.js` calls `changeInSafeStateV2(itemId)`, which executes: **Response 200:** Returns on successful toggle.
```sql **Response 500:**
UPDATE items SET in_safe = NOT in_safe WHERE id = ?
```
#### Example request
```http
POST https://backend.insta.the1s.de/api/change-state/12345678/42
```
(Will toggle `in_safe` for item `42`.)
#### Successful response (current implementation)
```json
{
"data": null
}
```
#### Error responses
Invalid `state` (anything other than `"0"` or `"1"`):
```json
{ "message": "Invalid state value" }
```
Failed update:
```json ```json
{ "message": "Failed to update item state" } { "message": "Failed to update item state" }
``` ```
#### Status codes
- `200 OK` item state toggled
- `400 Bad Request` invalid `state` parameter
- `401 Unauthorized` invalid / missing key
- `500 Internal Server Error` database/update failure or `success: false` from DB layer
--- ---
## 3) Loans ### Get Loan by Code
### 3.1 Get loan by code `GET /get-loan-by-code/:key/:loan_code`
**GET** `/api/get-loan-by-code/:key/:loan_code` Retrieves loan details by its 6-digit loan code.
Path parameters: **URL Parameters:**
- `:key` API key - **key** - API key
- `:loan_code` 6-digit loan code (`^[0-9]{6}$` per DB constraint) - **loan_code** - A 6-digit numeric loan code
Database layer (`getLoanByCodeV2`) currently selects: **Response 200:**
```sql
SELECT first_name, returned_date, take_date, lockers
FROM loans
WHERE loan_code = ?;
```
#### Example request
```http
GET https://backend.insta.the1s.de/api/get-loan-by-code/12345678/646473
```
#### Successful response
```json ```json
{ {
"data": { "data": {
"first_name": "Theis", "username": "jdoe",
"returned_date": null, "returned_date": null,
"take_date": "2025-08-25T13:23:00.000Z", "take_date": "2024-01-15T10:30:00.000Z",
"lockers": ["01", "03"] "lockers": [1, 3]
} }
} }
``` ```
#### Error response **Response 404:**
```json ```json
{ "message": "Loan not found" } { "message": "Loan not found" }
``` ```
#### Status codes ---
- `200 OK` loan found ### Set Take Date
- `401 Unauthorized` invalid / missing key
- `404 Not Found` no matching loan for this `loan_code` `POST /set-take-date/:key/:loan_code`
Records when items are physically taken by setting `take_date` to the current timestamp. Updates associated items to `in_safe = false` and sets `currently_borrowing` to the loan's username.
**URL Parameters:**
- **key** - API key
- **loan_code** - A 6-digit numeric loan code
**Response 200:** Empty JSON object on success.
**Response 500:**
```json
{ "message": "Loan not found or already taken" }
```
> **Note:** This endpoint will fail if the loan has already been taken or does not exist.
--- ---
### 3.2 Set take date ### Set Return Date
**POST** `/api/set-take-date/:key/:loan_code` `POST /set-return-date/:key/:loan_code`
Path parameters: Marks a loan as returned by setting `returned_date` to the current timestamp. Also updates all associated items to `in_safe = true`, clears `currently_borrowing`, and sets `last_borrowed_person`. Therefore, keep in mind that you must not call other endpoints that will change the safe state of an item after or before calling this endpoint, otherwise the state of the items will be inconsistent.
- `:key` API key **URL Parameters:**
- `:loan_code` loan code
#### Example request - **key** - API key
- **loan_code** - A 6-digit numeric loan code
```http **Response 200:** Empty JSON object on success.
POST https://backend.insta.the1s.de/api/set-take-date/12345678/646473
```
#### Successful response **Response 500:**
```json
{
"data": null
}
```
#### Error response
```json
{ "message": "Failed to set take date" }
```
#### Status codes
- `200 OK` take date set and items marked as out
- `401 Unauthorized` invalid / missing key
- `500 Internal Server Error` invalid loan, missing items, or DB error / `success: false`
---
### 3.3 Set return date
**POST** `/api/set-return-date/:key/:loan_code`
Path parameters:
- `:key` API key
- `:loan_code` loan code
#### Example request
```http
POST https://backend.insta.the1s.de/api/set-return-date/12345678/646473
```
#### Successful response (current implementation)
```json
{
"data": null
}
```
#### Error response
```json ```json
{ "message": "Failed to set return date" } { "message": "Failed to set return date" }
``` ```
#### Status codes > **Note:** This endpoint will fail if the loan has already been returned (i.e., `returned_date` is not `NULL`).
- `200 OK` return date set and items marked as returned
- `401 Unauthorized` invalid / missing key
- `500 Internal Server Error` invalid loan, missing items, or DB error / `success: false`
--- ---
## Common Response Shapes ### Open Door
**Success list (authenticated items):** `GET /open-door/:key/:doorKey`
```json Toggles the safe state of an item identified by its door key and returns the associated safe number.
{
"data": [
/* array of rows */
]
}
```
**Success single loan:** **URL Parameters:**
- **key** - API key
- **doorKey** - The door key identifier assigned to an item
**Response 200:**
```json ```json
{ {
"data": { "data": {
/* selected loan fields */ "safe_nr": 3,
"id": 1
} }
} }
``` ```
**Success mutations (current code):** **Response 500:**
```json ```json
{ "data": null } { "message": "Failed to open door" }
``` ```
**Errors:** ## Error Handling
```json All endpoints return a `500` status code for server-side failures and a JSON body with a `message` field, except for **Get Loan by Code** which returns `404` when no matching loan is found.
{ "message": "Failed to fetch items" }
{ "message": "Failed to update item state" }
{ "message": "Invalid state value" }
{ "message": "Loan not found" }
{ "message": "Failed to set return date" }
{ "message": "Failed to set take date" }
```
**HTTP Status Codes:**
- `200 OK` operation succeeded
- `400 Bad Request` invalid `state` parameter
- `401 Unauthorized` invalid/missing API key
- `404 Not Found` loan not found
- `500 Internal Server Error` database / server failure or `success: false` from DB layer
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:18 as builder FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
+6 -2
View File
@@ -2,9 +2,13 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link
rel="icon"
type="image/png"
href="/icon_borrow-system-frontend_dark.png"
/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontendv2</title> <title>Ausleihsystem</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+8
View File
@@ -9,6 +9,14 @@ server {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
location = /backend {
return 301 /backend/;
}
location /backend/ {
proxy_pass http://demo_borrow_system-backend_v2:8102/;
}
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y; expires 1y;
access_log off; access_log off;
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "admin", "name": "admin",
"private": true, "private": true,
"version": "0.0.0", "version": "v2.1.2 (dev)",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shapes-icon lucide-shapes"><path d="M8.3 10a.7.7 0 0 1-.626-1.079L11.4 3a.7.7 0 0 1 1.198-.043L16.3 8.9a.7.7 0 0 1-.572 1.1Z"/><rect x="3" y="14" width="7" height="7" rx="1"/><circle cx="17.5" cy="17.5" r="3.5"/></svg>

Before

Width:  |  Height:  |  Size: 420 B

+4 -4
View File
@@ -12,7 +12,7 @@ import { triggerLogoutAtom } from "@/states/Atoms";
import { MyLoansPage } from "./pages/MyLoansPage"; import { MyLoansPage } from "./pages/MyLoansPage";
import Landingpage from "./pages/Landingpage"; import Landingpage from "./pages/Landingpage";
import { changeLanguage } from "i18next"; import { changeLanguage } from "i18next";
import { Box, Flex } from "@chakra-ui/react"; import { Flex } from "@chakra-ui/react";
import { Footer } from "./components/footer/Footer"; import { Footer } from "./components/footer/Footer";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { API_BASE } from "@/config/api.config"; import { API_BASE } from "@/config/api.config";
@@ -71,8 +71,8 @@ function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<Flex direction="column" minH="100vh"> <Flex direction="column" minH="100dvh">
<Box as="main" flex="1"> <Flex as="main" flex="1" direction="column">
<UserContext.Provider value={user}> <UserContext.Provider value={user}>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
@@ -86,7 +86,7 @@ function App() {
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</UserContext.Provider> </UserContext.Provider>
</Box> </Flex>
<Footer /> <Footer />
</Flex> </Flex>
</QueryClientProvider> </QueryClientProvider>
-50
View File
@@ -1,50 +0,0 @@
{
"title": "Changelog",
"items": [
{
"version": "v2.1.0",
"date": "2025-10-24",
"changes": [
{
"type": "Hinzugefügt",
"text": [
"Neue Changelog-Komponente mit zentriertem Layout.",
"Unterstützung für mehrsprachige Einträge (Englisch und Deutsch)."
]
},
{
"type": "Verbessert",
"text": [
"Performance-Optimierungen beim Laden der Listenansichten.",
"Verbesserte Barrierefreiheit durch ARIA-Attribute."
]
},
{
"type": "Behoben",
"text": [
"Fehler bei der Datumsauswahl im Safari-Browser.",
"Anzeigeprobleme bei hohen DPI-Einstellungen."
]
}
]
},
{
"version": "v2.0.3",
"date": "2025-10-10",
"changes": [
{
"type": "Geändert",
"text": [
"Standard-Timeout für API-Requests auf 10s erhöht."
]
},
{
"type": "Sicherheit",
"text": [
"Abhängigkeiten aktualisiert (kritische CVEs behoben)."
]
}
]
}
]
}
-263
View File
@@ -1,263 +0,0 @@
import { useEffect, useRef, useState } from "react";
const STORAGE_KEY = "changelog";
type ChangeType =
| "Hinzugefügt"
| "Geändert"
| "Behoben"
| "Entfernt"
| "Verbessert"
| "Sicherheit"
| "Veraltet"
| string;
type ChangeEntry = {
type: ChangeType;
text: string | string[]; // aus localStorage kann es eine Liste sein
};
type ChangelogItem = {
version?: string;
date: string;
changes: ChangeEntry[];
};
type StoredChangelog = {
title: string;
items: ChangelogItem[];
};
const typeStyles: Record<string, string> = {
Hinzugefügt:
"bg-emerald-500/15 text-emerald-300 ring-1 ring-inset ring-emerald-500/30",
Geändert: "bg-blue-500/15 text-blue-300 ring-1 ring-inset ring-blue-500/30",
Behoben: "bg-amber-500/15 text-amber-300 ring-1 ring-inset ring-amber-500/30",
Entfernt: "bg-rose-500/15 text-rose-300 ring-1 ring-inset ring-rose-500/30",
Verbessert:
"bg-indigo-500/15 text-indigo-300 ring-1 ring-inset ring-indigo-500/30",
Sicherheit: "bg-red-500/15 text-red-300 ring-1 ring-inset ring-red-500/30",
Veraltet: "bg-zinc-700/30 text-zinc-300 ring-1 ring-inset ring-zinc-600/40",
};
export default function Changelog() {
const [open, setOpen] = useState(true);
const [mounted, setMounted] = useState(false);
const [data, setData] = useState<StoredChangelog | null>(null);
const [error, setError] = useState<string | null>(null);
const cardRef = useRef<HTMLDivElement | null>(null);
useEffect(() => setMounted(true), []);
const loadFromStorage = () => {
try {
setError(null);
const raw =
typeof window !== "undefined"
? localStorage.getItem(STORAGE_KEY)
: null;
if (!raw) {
setData(null);
return;
}
const parsed = JSON.parse(raw) as StoredChangelog;
if (!parsed || !Array.isArray(parsed.items)) {
throw new Error("Ungültiges Format");
}
setData(parsed);
} catch (e) {
setError("Changelog konnte nicht aus localStorage geladen werden.");
setData(null);
}
};
useEffect(() => {
loadFromStorage();
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
const onClickOutside = (e: MouseEvent) => {
if (cardRef.current && !cardRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
const onStorage = (e: StorageEvent) => {
if (e.key === STORAGE_KEY) loadFromStorage();
};
window.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onClickOutside);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onClickOutside);
window.removeEventListener("storage", onStorage);
};
}, []);
if (!open) return null;
const title = data?.title ?? "Changelog";
const items = data?.items ?? [];
return (
<div className="min-h-screen bg-zinc-950 bg-[radial-gradient(60%_60%_at_50%_0%,rgba(99,102,241,0.12),rgba(24,24,27,0))] flex items-center justify-center p-6">
<div
ref={cardRef}
className={[
"relative w-full max-w-6xl transition-all duration-300 ease-out",
mounted
? "opacity-100 translate-y-0 scale-100"
: "opacity-0 translate-y-1 scale-[0.99]",
].join(" ")}
aria-live="polite"
>
{/* Gradient border wrapper */}
<div className="rounded-2xl p-[1px] bg-gradient-to-b from-zinc-700/60 via-zinc-700/20 to-zinc-800/60 shadow-2xl">
{/* Card */}
<div className="relative rounded-[calc(theme(borderRadius.2xl)-1px)] border border-zinc-800/70 bg-zinc-900/70 supports-[backdrop-filter]:bg-zinc-900/60 backdrop-blur-xl ring-1 ring-white/10">
{/* Accent top line */}
<div className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-indigo-500/40 to-transparent" />
{/* Close button */}
<button
aria-label="Changelog schließen"
onClick={() => setOpen(false)}
className="absolute right-3 top-3 inline-flex h-9 w-9 items-center justify-center rounded-md text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500/70 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-900 transition"
>
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
{/* Header */}
<header className="px-10 pt-8 pb-6 border-b border-zinc-800/70">
<div className="flex items-center gap-3">
<div className="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-indigo-500/15 text-indigo-300 ring-1 ring-inset ring-indigo-500/30">
<svg
viewBox="0 0 24 24"
className="h-5 w-5"
fill="none"
stroke="currentColor"
strokeWidth={1.6}
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1" />
</svg>
</div>
<div>
<h1 className="text-[30px] leading-8 font-semibold text-zinc-100 tracking-[-0.01em]">
{title}
</h1>
<p className="text-sm text-zinc-400">
Aktuelle Änderungen und Updates
</p>
</div>
</div>
</header>
{/* Body */}
<div className="relative max-h-[78vh] overflow-y-auto">
<div className="absolute pointer-events-none inset-x-0 top-0 h-8 bg-gradient-to-b from-zinc-900/70 to-transparent" />
<div className="absolute pointer-events-none inset-x-0 bottom-0 h-10 bg-gradient-to-t from-zinc-900/80 to-transparent" />
{error && (
<div className="px-10 py-8">
<div className="rounded-lg border border-red-900/40 bg-red-900/10 px-4 py-3 text-sm text-red-300">
{error}
</div>
</div>
)}
{!error && items.length === 0 && (
<div className="px-10 py-16 text-center">
<p className="text-zinc-400">
Kein Changelog im localStorage gefunden (Key: {STORAGE_KEY}
).
</p>
</div>
)}
<ul className="divide-y divide-zinc-800/70">
{items.map((entry, idx) => (
<li
key={`${entry.version ?? entry.date}-${idx}`}
className="px-10 py-8"
>
{/* Kopfzeile je Release */}
<div className="flex flex-wrap items-baseline gap-x-4 gap-y-2">
{entry.version && (
<span className="inline-flex items-center rounded-md bg-gradient-to-b from-zinc-100 to-zinc-300 text-zinc-900 px-3 py-0.5 text-sm font-semibold shadow-sm">
{entry.version}
</span>
)}
<time
className="text-sm text-zinc-400"
dateTime={entry.date}
>
{new Date(entry.date).toLocaleDateString("de-DE", {
year: "numeric",
month: "long",
day: "2-digit",
})}
</time>
</div>
{/* Zweispaltiges Layout: Typ links, Text rechts (mit schöner Leselänge) */}
<dl
role="list"
className="mt-6 grid grid-cols-1 gap-x-8 gap-y-3 md:grid-cols-[max-content_1fr]"
>
{entry.changes.map((c, i) => (
<div key={i} className="contents">
<dt className="md:w-44 md:justify-end md:text-right">
<span
className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ${
typeStyles[c.type] ??
"bg-zinc-700/30 text-zinc-300 ring-1 ring-inset ring-zinc-600/40"
}`}
>
{c.type}
</span>
</dt>
<dd className="max-w-[74ch] text-[15px] leading-7 text-zinc-200 tracking-[0.005em]">
{Array.isArray(c.text) ? (
<ul className="ml-4 list-disc marker:text-zinc-500/70 space-y-1.5">
{c.text.map((t, k) => (
<li key={k} className="break-words">
{t}
</li>
))}
</ul>
) : (
<p className="break-words">{c.text}</p>
)}
</dd>
</div>
))}
</dl>
</li>
))}
</ul>
</div>
{/* soft bottom glow */}
<div className="pointer-events-none absolute inset-x-12 -bottom-4 h-8 blur-2xl bg-indigo-600/20 rounded-full" />
</div>
</div>
</div>
</div>
);
}
+8 -228
View File
@@ -4,26 +4,18 @@ import {
Heading, Heading,
Stack, Stack,
Text, Text,
CloseButton,
Dialog,
Portal,
HStack, HStack,
IconButton, IconButton,
Menu, Menu,
Box, Box,
Avatar, Avatar,
Card,
Grid,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { PasswordInput } from "@/components/ui/password-input";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms"; import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { import {
CircleUserRound, CircleUserRound,
RotateCcwKey,
Code,
LifeBuoy, LifeBuoy,
LogOut, LogOut,
CalendarPlus, CalendarPlus,
@@ -33,69 +25,19 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useUserContext } from "@/states/Context"; import { useUserContext } from "@/states/Context";
import { useState } from "react"; import { useState } from "react";
import MyAlert from "./myChakra/MyAlert";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { API_BASE } from "@/config/api.config"; import { UserDialogue } from "./UserDialogue";
export const Header = () => { export const Header = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const userData = useUserContext(); const userData = useUserContext();
console.log(userData);
const { t } = useTranslation(); const { t } = useTranslation();
// Error handling states
const [isMsg, setIsMsg] = useState(false);
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
const [msgTitle, setMsgTitle] = useState("");
const [msgDescription, setMsgDescription] = useState("");
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [, setTriggerLogout] = useAtom(triggerLogoutAtom); const [, setTriggerLogout] = useAtom(triggerLogoutAtom);
const [, setIsLoggedIn] = useAtom(setIsLoggedInAtom); const [, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
// Dialog control
const [isPwOpen, setPwOpen] = useState(false);
const [userDialog, setUserDialog] = useState(false); const [userDialog, setUserDialog] = useState(false);
const changePassword = async () => {
if (newPassword !== confirmPassword) {
setMsgTitle(t("err_pw_change"));
setMsgDescription(t("pw_mismatch"));
setMsgStatus("error");
setIsMsg(true);
return;
}
const response = await fetch(`${API_BASE}/api/users/change-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ oldPassword, newPassword }),
});
if (!response.ok) {
setMsgTitle(t("err_pw_change"));
setMsgDescription(t("pw_mismatch"));
setMsgStatus("error");
setIsMsg(true);
return;
}
setMsgTitle(t("pw_success"));
setMsgDescription(t("pw_success_desc"));
setMsgStatus("success");
setIsMsg(true);
setOldPassword("");
setNewPassword("");
setConfirmPassword("");
};
const username = userData.first_name ? userData.first_name : "N/A"; const username = userData.first_name ? userData.first_name : "N/A";
const fullname = userData.first_name + " " + userData.last_name; const fullname = userData.first_name + " " + userData.last_name;
const randomColor = [ const randomColor = [
@@ -199,9 +141,9 @@ export const Header = () => {
value="help" value="help"
onSelect={() => onSelect={() =>
window.open( window.open(
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki", "https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki/?action=_pages",
"_blank", "_blank",
"noopener,noreferrer" "noopener,noreferrer",
) )
} }
children={ children={
@@ -211,22 +153,6 @@ export const Header = () => {
</HStack> </HStack>
} }
/> />
<Menu.Item
value="source-code"
onSelect={() =>
window.open(
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system",
"_blank",
"noopener,noreferrer"
)
}
children={
<HStack gap={3}>
<Code size={16} />
<Text as="span">{t("source-code")}</Text>
</HStack>
}
/>
<Menu.Separator /> <Menu.Separator />
<Menu.Item <Menu.Item
value="logout" value="logout"
@@ -342,7 +268,7 @@ export const Header = () => {
</Button> </Button>
<a <a
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki" href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki/?action=_pages"
target="_blank" target="_blank"
> >
<Button variant="ghost"> <Button variant="ghost">
@@ -352,19 +278,6 @@ export const Header = () => {
</HStack> </HStack>
</Button> </Button>
</a> </a>
<a
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system"
target="_blank"
>
<Button variant="ghost">
<HStack gap={2}>
<Code size={18} />
<Text as="span">{t("source-code")}</Text>
</HStack>
</Button>
</a>
<Button onClick={logout} variant="outline" colorScheme="red"> <Button onClick={logout} variant="outline" colorScheme="red">
<HStack gap={2}> <HStack gap={2}>
<LogOut size={18} /> <LogOut size={18} />
@@ -376,145 +289,12 @@ export const Header = () => {
{/* User Info Dialoge */} {/* User Info Dialoge */}
{userDialog && ( {userDialog && (
<Flex <UserDialogue
position="fixed" setUserDialog={setUserDialog}
inset={0} fullname={fullname}
zIndex={1000} randomColor={randomColor}
align="center"
justify="center"
bg="blackAlpha.400"
backdropFilter="blur(6px)"
>
<Card.Root maxW="sm" w="full" mx={4}>
<Card.Header>
<Card.Title>
<Flex justify="center" align="center" w="100%">
<Avatar.Root
size={"2xl"}
colorPalette={randomColor[Math.floor(Math.random() * 10)]}
>
<Avatar.Fallback name={fullname} />
</Avatar.Root>
</Flex>
</Card.Title>
<Card.Description>{t("user-info-desc")}</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Box as="dl">
<Grid
templateColumns="auto 1fr"
rowGap={2}
columnGap={4}
alignItems="start"
>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("first-name")}:
</Text>
<Text as="dd">{userData.first_name}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("last-name")}:
</Text>
<Text as="dd">{userData.last_name}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("username")}:
</Text>
<Text as="dd">{userData.username}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("role")}:
</Text>
<Text as="dd">{userData.role}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("admin-status")}:
</Text>
<Text as="dd">
{userData.is_admin ? t("yes") : t("no")}
</Text>
</Grid>
</Box>
<Button variant="solid" onClick={() => setPwOpen(true)}>
<HStack gap={2}>
<RotateCcwKey size={18} />
<Text as="span">{t("change-password")}</Text>
</HStack>
</Button>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
<Button variant="outline" onClick={() => setUserDialog(false)}>
{t("cancel")}
</Button>
</Card.Footer>
</Card.Root>
</Flex>
)}
{/* Passwort-Dialog (kontrolliert) */}
<Dialog.Root open={isPwOpen} onOpenChange={(e: any) => setPwOpen(e.open)}>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content maxW="md">
<Dialog.Header>
<Dialog.Title>{t("change-password")}</Dialog.Title>
</Dialog.Header>
<form
onSubmit={(e) => {
e.preventDefault();
changePassword();
}}
>
<Dialog.Body>
<Stack gap={3}>
<PasswordInput
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder={t("old-password")}
/>
<PasswordInput
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t("new-password")}
/>
<PasswordInput
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t("confirm-password")}
/>
</Stack>
</Dialog.Body>
<Dialog.Footer>
<Stack w="100%" gap={3}>
{isMsg && (
<MyAlert
status={msgStatus}
title={msgTitle}
description={msgDescription}
/> />
)} )}
<HStack justify="flex-end" gap={2}>
<Dialog.ActionTrigger asChild>
<Button variant="outline">{t("cancel")}</Button>
</Dialog.ActionTrigger>
<Button type="submit" colorScheme="teal">
{t("save")}
</Button>
</HStack>
</Stack>
</Dialog.Footer>
</form>
<Dialog.CloseTrigger asChild>
<CloseButton size="sm" />
</Dialog.CloseTrigger>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
</Stack> </Stack>
); );
}; };
+220
View File
@@ -0,0 +1,220 @@
import {
Button,
Flex,
Stack,
Text,
CloseButton,
Dialog,
Portal,
HStack,
Box,
Avatar,
Card,
Grid,
} from "@chakra-ui/react";
import { PasswordInput } from "@/components/ui/password-input";
import { RotateCcwKey } from "lucide-react";
import MyAlert from "./myChakra/MyAlert";
import { API_BASE } from "@/config/api.config";
import { useUserContext } from "@/states/Context";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import Cookies from "js-cookie";
type UserDialogueProps = {
setUserDialog: (value: boolean) => void;
fullname: string;
randomColor: string[];
};
export const UserDialogue = (props: UserDialogueProps) => {
const userData = useUserContext();
const { t } = useTranslation();
// Error handling states
const [isMsg, setIsMsg] = useState(false);
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
const [msgTitle, setMsgTitle] = useState("");
const [msgDescription, setMsgDescription] = useState("");
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
// Dialog control
const [isPwOpen, setPwOpen] = useState(false);
const changePassword = async () => {
if (newPassword !== confirmPassword) {
setMsgTitle(t("err_pw_change"));
setMsgDescription(t("pw_mismatch"));
setMsgStatus("error");
setIsMsg(true);
return;
}
const response = await fetch(`${API_BASE}/api/users/change-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ oldPassword, newPassword }),
});
if (!response.ok) {
setMsgTitle(t("err_pw_change"));
setMsgDescription(t("pw_mismatch"));
setMsgStatus("error");
setIsMsg(true);
return;
}
setMsgTitle(t("pw_success"));
setMsgDescription(t("pw_success_desc"));
setMsgStatus("success");
setIsMsg(true);
setOldPassword("");
setNewPassword("");
setConfirmPassword("");
};
return (
<Flex
position="fixed"
inset={0}
zIndex={1000}
align="center"
justify="center"
bg="blackAlpha.400"
backdropFilter="blur(6px)"
>
<Card.Root maxW="sm" w="full" mx={4}>
<Card.Header>
<Card.Title>
<Flex justify="center" align="center" w="100%">
<Avatar.Root
size={"2xl"}
colorPalette={props.randomColor[Math.floor(Math.random() * 10)]}
>
<Avatar.Fallback name={props.fullname} />
</Avatar.Root>
</Flex>
</Card.Title>
<Card.Description>{t("user-info-desc")}</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Box as="dl">
<Grid
templateColumns="auto 1fr"
rowGap={2}
columnGap={4}
alignItems="start"
>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("first-name")}:
</Text>
<Text as="dd">{userData.first_name}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("last-name")}:
</Text>
<Text as="dd">{userData.last_name}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("username")}:
</Text>
<Text as="dd">{userData.username}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("role")}:
</Text>
<Text as="dd">{userData.role}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("admin-status")}:
</Text>
<Text as="dd">{userData.is_admin ? t("yes") : t("no")}</Text>
</Grid>
</Box>
<Button variant="solid" onClick={() => setPwOpen(true)}>
<HStack gap={2}>
<RotateCcwKey size={18} />
<Text as="span">{t("change-password")}</Text>
</HStack>
</Button>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
<Button variant="outline" onClick={() => props.setUserDialog(false)}>
{t("cancel")}
</Button>
</Card.Footer>
</Card.Root>
{/* Passwort-Dialog (kontrolliert) */}
<Dialog.Root open={isPwOpen} onOpenChange={(e: any) => setPwOpen(e.open)}>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content maxW="md">
<Dialog.Header>
<Dialog.Title>{t("change-password")}</Dialog.Title>
</Dialog.Header>
<form
onSubmit={(e) => {
e.preventDefault();
changePassword();
}}
>
<Dialog.Body>
<Stack gap={3}>
<PasswordInput
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder={t("old-password")}
/>
<PasswordInput
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t("new-password")}
/>
<PasswordInput
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t("confirm-password")}
/>
</Stack>
</Dialog.Body>
<Dialog.Footer>
<Stack w="100%" gap={3}>
{isMsg && (
<MyAlert
status={msgStatus}
title={msgTitle}
description={msgDescription}
/>
)}
<HStack justify="flex-end" gap={2}>
<Dialog.ActionTrigger asChild>
<Button variant="outline">{t("cancel")}</Button>
</Dialog.ActionTrigger>
<Button type="submit" colorScheme="teal">
{t("save")}
</Button>
</HStack>
</Stack>
</Dialog.Footer>
</form>
<Dialog.CloseTrigger asChild>
<CloseButton size="sm" />
</Dialog.CloseTrigger>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
</Flex>
);
};
+4 -5
View File
@@ -9,12 +9,11 @@ export const Footer = () => {
as="footer" as="footer"
py={4} py={4}
textAlign="center" textAlign="center"
position="fixed" width="100%"
bottom="0" flexShrink={0}
left="0" fontSize="sm"
right="0"
> >
Made with by Theis Gaedigk - Year 2019 at MCS-Bochum Made with by Theis Gaedigk - Class of 2019 at MCS-Bochum
<br /> <br />
Frontend-Version: {info ? info["frontend-info"].version : "N/A"} | Frontend-Version: {info ? info["frontend-info"].version : "N/A"} |
Backend-Version: {info ? info["backend-info"].version : "N/A"} Backend-Version: {info ? info["backend-info"].version : "N/A"}
+17 -9
View File
@@ -1,15 +1,23 @@
"use client" "use client";
import { ChakraProvider, defaultSystem } from "@chakra-ui/react" import { ChakraProvider, defaultSystem } from "@chakra-ui/react";
import { import * as React from "react";
ColorModeProvider, import type { ReactNode } from "react";
type ColorModeProviderProps, import { ColorModeProvider as ThemeColorModeProvider } from "./color-mode";
} from "./color-mode"
export function Provider(props: ColorModeProviderProps) { export interface ColorModeProviderProps {
children: React.ReactNode;
}
export function ColorModeProvider({ children }: ColorModeProviderProps) {
// Wrap children with the real color-mode provider
return <ThemeColorModeProvider>{children}</ThemeColorModeProvider>;
}
export function Provider({ children }: { children: ReactNode }) {
return ( return (
<ChakraProvider value={defaultSystem}> <ChakraProvider value={defaultSystem}>
<ColorModeProvider {...props} /> <ColorModeProvider>{children}</ColorModeProvider>
</ChakraProvider> </ChakraProvider>
) );
} }
-1
View File
@@ -108,7 +108,6 @@ export const HomePage = () => {
} }
setBorrowableItems(response.data); setBorrowableItems(response.data);
setIsMsg(false); setIsMsg(false);
console.log(borrowableItems);
}); });
}} }}
> >
+13 -14
View File
@@ -9,12 +9,13 @@ import {
Card, Card,
SimpleGrid, SimpleGrid,
Button, Button,
Container,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import MyAlert from "@/components/myChakra/MyAlert"; import MyAlert from "@/components/myChakra/MyAlert";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { API_BASE } from "@/config/api.config"; import { API_BASE } from "@/config/api.config";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { useNavigate } from "react-router-dom"; import { Header } from "@/components/Header";
export const formatDateTime = (value: string | null | undefined) => { export const formatDateTime = (value: string | null | undefined) => {
if (!value) return "N/A"; if (!value) return "N/A";
@@ -32,6 +33,7 @@ type Loan = {
returned_date: string | null; returned_date: string | null;
take_date: string | null; take_date: string | null;
loaned_items_name: string[] | string; loaned_items_name: string[] | string;
note: string | null;
}; };
type Device = { type Device = {
@@ -46,7 +48,6 @@ type Device = {
const Landingpage: React.FC = () => { const Landingpage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [loans, setLoans] = useState<Loan[]>([]); const [loans, setLoans] = useState<Loan[]>([]);
@@ -59,7 +60,7 @@ const Landingpage: React.FC = () => {
const setError = ( const setError = (
status: "error" | "success", status: "error" | "success",
message: string, message: string,
description: string description: string,
) => { ) => {
setIsError(false); setIsError(false);
setErrorStatus(status); setErrorStatus(status);
@@ -85,7 +86,7 @@ const Landingpage: React.FC = () => {
setError( setError(
"error", "error",
t("error-by-loading"), t("error-by-loading"),
t("unexpected-date-format_loan") t("unexpected-date-format_loan"),
); );
} }
@@ -102,7 +103,7 @@ const Landingpage: React.FC = () => {
setError( setError(
"error", "error",
t("error-by-loading"), t("error-by-loading"),
t("unexpected-date-format_device") t("unexpected-date-format_device"),
); );
} }
} catch (e) { } catch (e) {
@@ -115,14 +116,8 @@ const Landingpage: React.FC = () => {
}, []); }, []);
return ( return (
<> <Container className="px-6 sm:px-8 pt-10">
<Heading as="h1" size="lg" mb={2}> <Header />
Matthias-Claudius-Schule Technik
</Heading>
<Button onClick={() => navigate("/", { replace: true })}>
{t("back")}
</Button>
<Heading as="h2" size="md" mb={4}> <Heading as="h2" size="md" mb={4}>
{t("all-loans")} {t("all-loans")}
@@ -168,6 +163,9 @@ const Landingpage: React.FC = () => {
<Table.ColumnHeader> <Table.ColumnHeader>
<strong>{t("return-date")}</strong> <strong>{t("return-date")}</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader>
<strong>{t("note")}</strong>
</Table.ColumnHeader>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
@@ -184,6 +182,7 @@ const Landingpage: React.FC = () => {
</Table.Cell> </Table.Cell>
<Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell> <Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell>
<Table.Cell>{formatDateTime(loan.returned_date)}</Table.Cell> <Table.Cell>{formatDateTime(loan.returned_date)}</Table.Cell>
<Table.Cell>{loan.note}</Table.Cell>
</Table.Row> </Table.Row>
))} ))}
</Table.Body> </Table.Body>
@@ -260,7 +259,7 @@ const Landingpage: React.FC = () => {
</HStack> </HStack>
</Button> </Button>
</HStack> </HStack>
</> </Container>
); );
}; };
+9 -9
View File
@@ -4,10 +4,9 @@ import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms"; import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { Navigate, useNavigate } from "react-router-dom"; import { Navigate, useNavigate, useLocation } from "react-router-dom";
import { PasswordInput } from "@/components/ui/password-input"; import { PasswordInput } from "@/components/ui/password-input";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Footer } from "@/components/footer/Footer";
import { API_BASE } from "@/config/api.config"; import { API_BASE } from "@/config/api.config";
export const LoginPage = () => { export const LoginPage = () => {
@@ -16,13 +15,15 @@ export const LoginPage = () => {
const [isLoggedIn, setIsLoggedIn] = useAtom(setIsLoggedInAtom); const [isLoggedIn, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
const [triggerLogout, setTriggerLogout] = useAtom(triggerLogoutAtom); const [triggerLogout, setTriggerLogout] = useAtom(triggerLogoutAtom);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || "/";
useEffect(() => { useEffect(() => {
if (isLoggedIn) { if (isLoggedIn) {
navigate("/", { replace: true }); navigate(from, { replace: true });
window.location.reload(); // Wenn entfernt: Seite bleibt schwarz und muss manuell neu geladen werden window.location.reload(); // if deleted, the user context is not updated in time
} }
}, [isLoggedIn, navigate]); }, [isLoggedIn, navigate, from]);
const loginFnc = async (username: string, password: string) => { const loginFnc = async (username: string, password: string) => {
const response = await fetch(`${API_BASE}/api/users/login`, { const response = await fetch(`${API_BASE}/api/users/login`, {
@@ -61,15 +62,15 @@ export const LoginPage = () => {
return; return;
} }
setTriggerLogout(false); setTriggerLogout(false);
navigate("/", { replace: true }); navigate(from, { replace: true });
}; };
if (isLoggedIn) { if (isLoggedIn) {
return <Navigate to="/" replace />; return <Navigate to={from} replace />;
} }
return ( return (
<div className="min-h-screen flex items-center justify-center p-4"> <div className="flex flex-1 items-center justify-center p-4">
<form onSubmit={(e) => e.preventDefault()}> <form onSubmit={(e) => e.preventDefault()}>
<Card.Root maxW="sm"> <Card.Root maxW="sm">
<Card.Header> <Card.Header>
@@ -113,7 +114,6 @@ export const LoginPage = () => {
</Card.Footer> </Card.Footer>
</Card.Root> </Card.Root>
</form> </form>
<Footer />
</div> </div>
); );
}; };
+107 -2
View File
@@ -112,6 +112,86 @@ export const MyLoansPage = () => {
return `${d}.${M}.${y} ${h}:${min}`; return `${d}.${M}.${y} ${h}:${min}`;
}; };
const handleTakeAction = async (loanCode: string) => {
try {
const res = await fetch(
`${API_BASE}/api/loans/set-take-date/${loanCode}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
},
);
if (!res.ok) {
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("error-take-loan"));
setIsMsg(true);
return;
}
// Update the loan in state
setLoans((prev) =>
prev.map((loan) =>
loan.loan_code === loanCode
? { ...loan, take_date: new Date().toISOString() }
: loan,
),
);
setMsgStatus("success");
setMsgTitle(t("success"));
setMsgDescription(t("take-loan-success"));
setIsMsg(true);
} catch (e) {
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("network-error"));
setIsMsg(true);
}
};
const handleReturnAction = async (loanCode: string) => {
try {
const res = await fetch(
`${API_BASE}/api/loans/set-return-date/${loanCode}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
},
);
if (!res.ok) {
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("error-return-loan"));
setIsMsg(true);
return;
}
// Update the loan in state
setLoans((prev) =>
prev.map((loan) =>
loan.loan_code === loanCode
? { ...loan, returned_date: new Date().toISOString() }
: loan,
),
);
setMsgStatus("success");
setMsgTitle(t("success"));
setMsgDescription(t("return-loan-success"));
setIsMsg(true);
} catch (e) {
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("network-error"));
setIsMsg(true);
}
};
return ( return (
<> <>
<Container className="px-6 sm:px-8 pt-10"> <Container className="px-6 sm:px-8 pt-10">
@@ -190,8 +270,33 @@ export const MyLoansPage = () => {
: "-"} : "-"}
</Text> </Text>
</Table.Cell> </Table.Cell>
<Table.Cell>{formatDate(loan.take_date)}</Table.Cell> <Table.Cell>
<Table.Cell>{formatDate(loan.returned_date)}</Table.Cell> {loan.take_date ? (
formatDate(loan.take_date)
) : (
<Button
size="xs"
colorPalette="teal"
onClick={() => handleTakeAction(loan.loan_code)}
>
{t("take")}
</Button>
)}
</Table.Cell>
<Table.Cell>
{loan.returned_date ? (
formatDate(loan.returned_date)
) : (
<Button
size="xs"
colorPalette="blue"
onClick={() => handleReturnAction(loan.loan_code)}
disabled={!loan.take_date}
>
{t("return")}
</Button>
)}
</Table.Cell>
<Table.Cell>{loan.note}</Table.Cell> <Table.Cell>{loan.note}</Table.Cell>
<Table.Cell> <Table.Cell>
<Dialog.Root role="alertdialog"> <Dialog.Root role="alertdialog">
+3 -3
View File
@@ -3,7 +3,7 @@ import { API_BASE } from "@/config/api.config";
export const getBorrowableItems = async ( export const getBorrowableItems = async (
startDate: string, startDate: string,
endDate: string endDate: string,
) => { ) => {
try { try {
const response = await fetch(`${API_BASE}/api/loans/borrowable-items`, { const response = await fetch(`${API_BASE}/api/loans/borrowable-items`, {
@@ -22,7 +22,7 @@ export const getBorrowableItems = async (
status: "error", status: "error",
title: "Server error", title: "Server error",
description: description:
"Ein Fehler ist auf dem Server aufgetreten. Manchmal hilft es, die Seite neu zu laden.", "An error occurred on the server. Sometimes reloading the page helps. Otherwise, please contact the administrator.",
}; };
} }
@@ -48,7 +48,7 @@ export const createLoan = async (
itemIds: number[], itemIds: number[],
startDate: string, startDate: string,
endDate: string, endDate: string,
note: string | null note: string | null,
) => { ) => {
const response = await fetch(`${API_BASE}/api/loans/createLoan`, { const response = await fetch(`${API_BASE}/api/loans/createLoan`, {
method: "POST", method: "POST",
+19 -3
View File
@@ -63,14 +63,30 @@
"timezone-info": "Die angezeigten Daten und Uhrzeiten werden in deutscher Zeitzone dargestellt und müssen auch so eingegeben werden.", "timezone-info": "Die angezeigten Daten und Uhrzeiten werden in deutscher Zeitzone dargestellt und müssen auch so eingegeben werden.",
"optional-note": "Optionale Notiz", "optional-note": "Optionale Notiz",
"note": "Notiz", "note": "Notiz",
"user-info-desc": "Hier können Sie Ihre persönlichen Informationen einsehen und ändern.", "user-info-desc": "Hier können Sie Ihre persönlichen Informationen einsehen und das Passwort ändern. Falls Sie weitere Änderungen benötigen, wenden Sie sich bitte an einen Administrator.",
"role": "Rolle", "role": "Rolle",
"admin-status": "Admin-Status", "admin-status": "Admin-Status",
"first-name": "Vorname", "first-name": "Vorname",
"last-name": "Nachname", "last-name": "Nachname",
"app-title": "Ausleihsystem", "app-title": "Ausleihsystem (demo)",
"last-borrowed-person": "Zuletzt ausgeliehen von", "last-borrowed-person": "Zuletzt ausgeliehen von",
"currently-borrowed-by": "Derzeit ausgeliehen von", "currently-borrowed-by": "Derzeit ausgeliehen von",
"back": "Zurückgehen", "back": "Zurückgehen",
"landingpage": "Übersichtsseite" "landingpage": "Übersichtsseite",
"contactPage_successHeadline": "Nachricht erfolgreich gesendet",
"contactPage_successText": "Vielen Dank, dass Sie uns kontaktiert haben. Wir werden uns so schnell wie möglich bei Ihnen melden.",
"contactPage_errorHeadline": "Fehler beim Senden der Nachricht",
"contactPage_errorText": "Beim Senden Ihrer Nachricht ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.",
"contactPage_sendButton": "Nachricht senden",
"contactPage_messageLabel": "Nachricht",
"contactPage_messagePlaceholder": "Geben Sie hier Ihre Nachricht ein...",
"contactPage_messageErrorText": "Dieses Feld darf nicht leer sein.",
"contact": "Kontakt",
"take": "Abholen",
"return": "Zurückgeben",
"serverError": "Serverfehler. Bitte versuchen Sie es später erneut, oder laden Sie die Seite neu.",
"take-loan-success": "Ausleihe erfolgreich abgeholt",
"return-loan-success": "Ausleihe erfolgreich zurückgegeben",
"network-error": "Netzwerkfehler. Kontaktieren Sie den Administrator.",
"contactPage_messageDescription": "Bitte geben Sie hier Ihre Nachricht ein. Der Systemadministrator (Theis Gaedigk) wird sich so schnell wie möglich bei Ihnen melden."
} }
+19 -3
View File
@@ -63,14 +63,30 @@
"timezone-info": "The displayed dates and times are shown in Berlin timezone and must also be entered as such.", "timezone-info": "The displayed dates and times are shown in Berlin timezone and must also be entered as such.",
"optional-note": "Optional note", "optional-note": "Optional note",
"note": "Note", "note": "Note",
"user-info-desc": "Here you can view and edit your personal information.", "user-info-desc": "Here you can view your personal information and change your password. If you need to make further changes, please contact an administrator.",
"role": "Role", "role": "Role",
"admin-status": "Admin status", "admin-status": "Admin status",
"first-name": "First name", "first-name": "First name",
"last-name": "Last name", "last-name": "Last name",
"app-title": "Borrow System", "app-title": "Borrow System (demo)",
"last-borrowed-person": "Last borrowed by", "last-borrowed-person": "Last borrowed by",
"currently-borrowed-by": "Currently borrowed by", "currently-borrowed-by": "Currently borrowed by",
"back": "Go back", "back": "Go back",
"landingpage": "Overview page" "landingpage": "Overview page",
"contactPage_successHeadline": "Message sent successfully",
"contactPage_successText": "Thank you for contacting us. We will get back to you as soon as possible.",
"contactPage_errorHeadline": "Error sending message",
"contactPage_errorText": "An error occurred while sending your message. Please try again later.",
"contactPage_sendButton": "Send message",
"contactPage_messageLabel": "Message",
"contactPage_messagePlaceholder": "Enter your message here...",
"contactPage_messageErrorText": "This field cannot be empty.",
"contact": "Contact",
"serverError": "Server error. Please try again later, or refresh the page.",
"take": "Take",
"return": "Return",
"take-loan-success": "Loan taken successfully",
"return-loan-success": "Loan returned successfully",
"network-error": "Network error. Please contact the administrator.",
"contactPage_messageDescription": "Please enter your message here. The system administrator (Theis Gaedigk) will get back to you as soon as possible."
} }
+6
View File
@@ -1,8 +1,14 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import path from "node:path";
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss()], plugins: [tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
allowedHosts: ["insta.the1s.de"], allowedHosts: ["insta.the1s.de"],
+6
View File
@@ -0,0 +1,6 @@
Copyright (c) 2026 Theis Gaedigk
All rights reserved.
This source code is not to be copied, modified, or distributed in any form
without explicit written permission from the author.
+1 -1
View File
@@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
+8
View File
@@ -9,6 +9,14 @@ server {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
location = /backend {
return 301 /backend/;
}
location /backend/ {
proxy_pass http://demo_borrow_system-backend_v2:8102/;
}
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ { location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y; expires 1y;
access_log off; access_log off;
+28 -40
View File
@@ -3675,12 +3675,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/cookie": { "node_modules/cookie": {
"version": "1.0.2", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/cosmiconfig": { "node_modules/cosmiconfig": {
@@ -4466,9 +4470,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.0", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@@ -4904,9 +4908,9 @@
} }
}, },
"node_modules/minizlib": { "node_modules/minizlib": {
"version": "3.0.2", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"minipass": "^7.1.2" "minipass": "^7.1.2"
@@ -4915,21 +4919,6 @@
"node": ">= 18" "node": ">= 18"
} }
}, },
"node_modules/mkdirp": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
"integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
"license": "MIT",
"bin": {
"mkdirp": "dist/cjs/src/bin.js"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5307,9 +5296,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.8.2", "version": "7.13.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.2.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
"integrity": "sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==", "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
@@ -5329,12 +5318,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.8.2", "version": "7.13.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.2.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz",
"integrity": "sha512-Z4VM5mKDipal2jQ385H6UBhiiEDlnJPx6jyWsTYoZQdl5TrjxEV2a9yl3Fi60NBJxYzOTGTTHXPi0pdizvTwow==", "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.8.2" "react-router": "7.13.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -5492,9 +5481,9 @@
} }
}, },
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
"version": "2.7.1", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/shebang-command": { "node_modules/shebang-command": {
@@ -5649,16 +5638,15 @@
} }
}, },
"node_modules/tar": { "node_modules/tar": {
"version": "7.4.3", "version": "7.5.7",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
"integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
"license": "ISC", "license": "BlueOak-1.0.0",
"dependencies": { "dependencies": {
"@isaacs/fs-minipass": "^4.0.0", "@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0", "chownr": "^3.0.0",
"minipass": "^7.1.2", "minipass": "^7.1.2",
"minizlib": "^3.0.1", "minizlib": "^3.1.0",
"mkdirp": "^3.0.1",
"yallist": "^5.0.0" "yallist": "^5.0.0"
}, },
"engines": { "engines": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "admin", "name": "admin",
"private": true, "private": true,
"version": "0.0.0", "version": "v1.3.2 (dev)",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2 -2
View File
@@ -3,6 +3,7 @@ import { useState } from "react";
import { loginFunc } from "@/utils/loginUser"; import { loginFunc } from "@/utils/loginUser";
import MyAlert from "../components/myChakra/MyAlert"; import MyAlert from "../components/myChakra/MyAlert";
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react"; import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
import { PasswordInput } from "@/components/ui/password-input";
const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => { const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
@@ -43,8 +44,7 @@ const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
</Field.Root> </Field.Root>
<Field.Root> <Field.Root>
<Field.Label>password</Field.Label> <Field.Label>password</Field.Label>
<Input <PasswordInput
type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
-1
View File
@@ -63,7 +63,6 @@ const APIKeyTable: React.FC = () => {
} }
); );
const data = await response.json(); const data = await response.json();
console.log(data);
return data; return data;
} catch (error) { } catch (error) {
setError("error", "Failed to fetch items", "There is an error"); setError("error", "Failed to fetch items", "There is an error");
+6 -8
View File
@@ -29,8 +29,8 @@ const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
<Input id="item_name" placeholder="z.B. Laptop" /> <Input id="item_name" placeholder="z.B. Laptop" />
</Field.Root> </Field.Root>
<Field.Root> <Field.Root>
<Field.Label>Schließfachnummer (immer zwei Zahlen)</Field.Label> <Field.Label>Schließfachnummer</Field.Label>
<Input id="lockerNumber" placeholder="Nummer 01 - 06" /> <Input id="safe_nr" placeholder="Nummer 1 - 6" />
</Field.Root> </Field.Root>
<Field.Root> <Field.Root>
<Field.Label>Ausleih-Berechtigung (Rolle)</Field.Label> <Field.Label>Ausleih-Berechtigung (Rolle)</Field.Label>
@@ -57,17 +57,15 @@ const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
(document.getElementById("can_borrow_role") as HTMLInputElement) (document.getElementById("can_borrow_role") as HTMLInputElement)
?.value ?.value
); );
const lockerValue = ( const safeNrValue = (
document.getElementById("lockerNumber") as HTMLInputElement document.getElementById("safe_nr") as HTMLInputElement
)?.value.trim(); )?.value.trim();
const lockerNumber = const safeNr = safeNrValue === "" ? null : safeNrValue;
lockerValue === "" ? null : Number(lockerValue);
if (!name || Number.isNaN(role)) return; if (!name || Number.isNaN(role)) return;
if (lockerNumber !== null && Number.isNaN(lockerNumber)) return;
const res = await createItem(name, role, lockerNumber); const res = await createItem(name, role, safeNr);
if (res.success) { if (res.success) {
alert( alert(
"success", "success",
+31 -5
View File
@@ -38,6 +38,7 @@ type Items = {
can_borrow_role: string; can_borrow_role: string;
in_safe: boolean; in_safe: boolean;
safe_nr: string; safe_nr: string;
door_key: string;
entry_created_at: string; entry_created_at: string;
entry_updated_at: string; entry_updated_at: string;
last_borrowed_person: string | null; last_borrowed_person: string | null;
@@ -68,7 +69,13 @@ const ItemTable: React.FC = () => {
const handleLockerNumberChange = (id: number, value: string) => { const handleLockerNumberChange = (id: number, value: string) => {
setItems((prev) => setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, lockerNumber: value } : it)) prev.map((it) => (it.id === id ? { ...it, safe_nr: value } : it))
);
};
const handleDoorKeyChange = (id: number, value: string) => {
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, door_key: value } : it))
); );
}; };
@@ -186,7 +193,12 @@ const ItemTable: React.FC = () => {
{/* make table fill available width, like UserTable */} {/* make table fill available width, like UserTable */}
{!isLoading && ( {!isLoading && (
<Table.Root size="sm" striped w="100%" style={{ tableLayout: "auto" }}> <Table.Root
size="sm"
striped
w="100%"
style={{ tableLayout: "auto" }} // Spalten nach Content
>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.ColumnHeader> <Table.ColumnHeader>
@@ -201,9 +213,12 @@ const ItemTable: React.FC = () => {
<Table.ColumnHeader> <Table.ColumnHeader>
<strong>Im Schließfach</strong> <strong>Im Schließfach</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader> <Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Schließfachnummer</strong> <strong>Schließfachnummer</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Schlüssel</strong>
</Table.ColumnHeader>
<Table.ColumnHeader> <Table.ColumnHeader>
<strong>Eintrag erstellt am</strong> <strong>Eintrag erstellt am</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
@@ -216,7 +231,7 @@ const ItemTable: React.FC = () => {
<Table.ColumnHeader> <Table.ColumnHeader>
<strong>Dav **</strong> <strong>Dav **</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader> <Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Aktionen</strong> <strong>Aktionen</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
</Table.Row> </Table.Row>
@@ -290,17 +305,28 @@ const ItemTable: React.FC = () => {
value={item.safe_nr} value={item.safe_nr}
/> />
</Table.Cell> </Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleDoorKeyChange(item.id, e.target.value)
}
value={item.door_key}
/>
</Table.Cell>
<Table.Cell>{formatDateTime(item.entry_created_at)}</Table.Cell> <Table.Cell>{formatDateTime(item.entry_created_at)}</Table.Cell>
<Table.Cell>{formatDateTime(item.entry_updated_at)}</Table.Cell> <Table.Cell>{formatDateTime(item.entry_updated_at)}</Table.Cell>
<Table.Cell>{item.last_borrowed_person}</Table.Cell> <Table.Cell>{item.last_borrowed_person}</Table.Cell>
<Table.Cell>{item.currently_borrowing}</Table.Cell> <Table.Cell>{item.currently_borrowing}</Table.Cell>
<Table.Cell> <Table.Cell whiteSpace="nowrap">
<Button <Button
onClick={() => onClick={() =>
handleEditItems( handleEditItems(
item.id, item.id,
item.item_name, item.item_name,
item.safe_nr, item.safe_nr,
item.door_key,
item.can_borrow_role item.can_borrow_role
).then((response) => { ).then((response) => {
if (response.success) { if (response.success) {
-1
View File
@@ -85,7 +85,6 @@ const UserTable: React.FC = () => {
setIsLoading(true); setIsLoading(true);
try { try {
const data = await fetchUserData(); const data = await fetchUserData();
console.log(data);
if (Array.isArray(data)) { if (Array.isArray(data)) {
setUsers(data); setUsers(data);
} else { } else {
+159
View File
@@ -0,0 +1,159 @@
"use client"
import type {
ButtonProps,
GroupProps,
InputProps,
StackProps,
} from "@chakra-ui/react"
import {
Box,
HStack,
IconButton,
Input,
InputGroup,
Stack,
mergeRefs,
useControllableState,
} from "@chakra-ui/react"
import * as React from "react"
import { LuEye, LuEyeOff } from "react-icons/lu"
export interface PasswordVisibilityProps {
/**
* The default visibility state of the password input.
*/
defaultVisible?: boolean
/**
* The controlled visibility state of the password input.
*/
visible?: boolean
/**
* Callback invoked when the visibility state changes.
*/
onVisibleChange?: (visible: boolean) => void
/**
* Custom icons for the visibility toggle button.
*/
visibilityIcon?: { on: React.ReactNode; off: React.ReactNode }
}
export interface PasswordInputProps
extends InputProps,
PasswordVisibilityProps {
rootProps?: GroupProps
}
export const PasswordInput = React.forwardRef<
HTMLInputElement,
PasswordInputProps
>(function PasswordInput(props, ref) {
const {
rootProps,
defaultVisible,
visible: visibleProp,
onVisibleChange,
visibilityIcon = { on: <LuEye />, off: <LuEyeOff /> },
...rest
} = props
const [visible, setVisible] = useControllableState({
value: visibleProp,
defaultValue: defaultVisible || false,
onChange: onVisibleChange,
})
const inputRef = React.useRef<HTMLInputElement>(null)
return (
<InputGroup
endElement={
<VisibilityTrigger
disabled={rest.disabled}
onPointerDown={(e) => {
if (rest.disabled) return
if (e.button !== 0) return
e.preventDefault()
setVisible(!visible)
}}
>
{visible ? visibilityIcon.off : visibilityIcon.on}
</VisibilityTrigger>
}
{...rootProps}
>
<Input
{...rest}
ref={mergeRefs(ref, inputRef)}
type={visible ? "text" : "password"}
/>
</InputGroup>
)
})
const VisibilityTrigger = React.forwardRef<HTMLButtonElement, ButtonProps>(
function VisibilityTrigger(props, ref) {
return (
<IconButton
tabIndex={-1}
ref={ref}
me="-2"
aspectRatio="square"
size="sm"
variant="ghost"
height="calc(100% - {spacing.2})"
aria-label="Toggle password visibility"
{...props}
/>
)
},
)
interface PasswordStrengthMeterProps extends StackProps {
max?: number
value: number
}
export const PasswordStrengthMeter = React.forwardRef<
HTMLDivElement,
PasswordStrengthMeterProps
>(function PasswordStrengthMeter(props, ref) {
const { max = 4, value, ...rest } = props
const percent = (value / max) * 100
const { label, colorPalette } = getColorPalette(percent)
return (
<Stack align="flex-end" gap="1" ref={ref} {...rest}>
<HStack width="full" {...rest}>
{Array.from({ length: max }).map((_, index) => (
<Box
key={index}
height="1"
flex="1"
rounded="sm"
data-selected={index < value ? "" : undefined}
layerStyle="fill.subtle"
colorPalette="gray"
_selected={{
colorPalette,
layerStyle: "fill.solid",
}}
/>
))}
</HStack>
{label && <HStack textStyle="xs">{label}</HStack>}
</Stack>
)
})
function getColorPalette(percent: number) {
switch (true) {
case percent < 33:
return { label: "Low", colorPalette: "red" }
case percent < 66:
return { label: "Medium", colorPalette: "orange" }
default:
return { label: "High", colorPalette: "green" }
}
}
+4 -5
View File
@@ -165,9 +165,8 @@ export const deleteItem = async (itemId: number) => {
export const createItem = async ( export const createItem = async (
item_name: string, item_name: string,
can_borrow_role: number, can_borrow_role: number,
lockerNumber: number | null lockerNumber: string | null
) => { ) => {
console.log(JSON.stringify({ item_name, can_borrow_role, lockerNumber }));
try { try {
const response = await fetch( const response = await fetch(
`${API_BASE}/api/admin/item-data/create-item`, `${API_BASE}/api/admin/item-data/create-item`,
@@ -184,7 +183,7 @@ export const createItem = async (
return { return {
success: false, success: false,
message: message:
"Fehler beim Erstellen des Gegenstands. Der Name des Gegenstandes darf nicht mehrmals vergeben werden.", "Fehler beim Erstellen des Gegenstands. Der Name des Gegenstandes und die Schließfachnummer dürfen nicht mehrmals vergeben werden.",
}; };
} }
return { success: true }; return { success: true };
@@ -198,9 +197,9 @@ export const handleEditItems = async (
itemId: number, itemId: number,
item_name: string, item_name: string,
safe_nr: string | null, safe_nr: string | null,
door_key: string | null,
can_borrow_role: string can_borrow_role: string
) => { ) => {
const newSafeNr = Number(safe_nr || 0);
try { try {
const response = await fetch( const response = await fetch(
`${API_BASE}/api/admin/item-data/edit-item/${itemId}`, `${API_BASE}/api/admin/item-data/edit-item/${itemId}`,
@@ -210,7 +209,7 @@ export const handleEditItems = async (
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,
}, },
body: JSON.stringify({ item_name, newSafeNr, can_borrow_role }), body: JSON.stringify({ item_name, safe_nr, door_key, can_borrow_role }),
} }
); );
if (!response.ok) { if (!response.ok) {
+4 -7
View File
@@ -1,10 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ESNext", "target": "ES2022",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"], "lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true, "skipLibCheck": true,
/* Bundler mode */ /* Bundler mode */
@@ -23,14 +24,10 @@
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true, "noUncheckedSideEffectImports": true,
/* Chakra / Pfad Aliases */ /* Path aliases */
"baseUrl": ".",
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
}, }
"forceConsistentCasingInFileNames": true,
"ignoreDeprecations": "5.0"
}, },
"include": ["src"] "include": ["src"]
} }
+3 -3
View File
@@ -1,11 +1,11 @@
{ {
"backend-info": { "backend-info": {
"version": "v2.0" "version": "v2.1.1 (demo)"
}, },
"frontend-info": { "frontend-info": {
"version": "v2.0" "version": "v2.1.2 (demo)"
}, },
"admin-panel-info": { "admin-panel-info": {
"version": "v1.2" "version": "v1.3.2 (demo)"
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "backendv2", "name": "backendv2",
"version": "1.0.0", "version": "v2.1.1 (dev)",
"main": "server.js", "main": "server.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1", "test": "echo \"Error: no test specified\" && exit 1",
@@ -32,10 +32,22 @@ export const createItem = async (item_name, can_borrow_role, lockerNumber) => {
return { success: false }; return { success: false };
}; };
export const editItemById = async (itemId, item_name, can_borrow_role) => { export const editItemById = async (
itemId,
item_name,
can_borrow_role,
safe_nr,
door_key
) => {
let newSafeNr;
if (safe_nr === null || safe_nr === "") {
newSafeNr = null;
} else {
newSafeNr = safe_nr;
}
const [result] = await pool.query( const [result] = await pool.query(
"UPDATE items SET item_name = ?, can_borrow_role = ?, entry_updated_at = NOW() WHERE id = ?", "UPDATE items SET item_name = ?, can_borrow_role = ?, safe_nr = ?, door_key = ?, entry_updated_at = NOW() WHERE id = ?",
[item_name, can_borrow_role, itemId] [item_name, can_borrow_role, newSafeNr, door_key, itemId]
); );
if (result.affectedRows > 0) return { success: true }; if (result.affectedRows > 0) return { success: true };
return { success: false }; return { success: false };
@@ -18,26 +18,26 @@ export const createUser = async (
isAdmin, isAdmin,
email, email,
first_name, first_name,
last_name last_name,
) => { ) => {
const [result] = await pool.query( const [result] = await pool.query(
"INSERT INTO users (username, role, password, is_admin, email, first_name, last_name) VALUES (?, ?, ?, ?, ?, ?, ?)", "INSERT INTO users (username, role, password, is_admin, email, first_name, last_name) VALUES (?, ?, ?, ?, ?, ?, ?)",
[username, role, password, isAdmin, email, first_name, last_name] [username, role, password, isAdmin, email, first_name, last_name],
); );
if (result.affectedRows > 0) return { success: true }; if (result.affectedRows > 0) return { success: true };
return { success: false }; return { success: false };
}; };
export const deleteUserById = async (userId) => { export const deleteUserById = async (userId) => {
const [result] = await pool.query("DELETE FROM users WHERE id = ?", [userId]); const [result] = await pool.query("DELETE FROM users WHERE id = ? AND secret_user = false", [userId]);
if (result.affectedRows > 0) return { success: true }; if (result.affectedRows > 0) return { success: true };
return { success: false }; return { success: false };
}; };
export const changePassword = async (userId, newPassword) => { export const changePassword = async (username, newPassword) => {
const [result] = await pool.query( const [result] = await pool.query(
"UPDATE users SET password = ?, entry_updated_at = NOW() WHERE id = ?", "UPDATE users SET password = ?, entry_updated_at = NOW() WHERE username = ? AND secret_user = false",
[newPassword, userId] [newPassword, username],
); );
if (result.affectedRows > 0) return { success: true }; if (result.affectedRows > 0) return { success: true };
return { success: false }; return { success: false };
@@ -49,11 +49,11 @@ export const editUserById = async (
last_name, last_name,
role, role,
email, email,
is_admin is_admin,
) => { ) => {
const [result] = await pool.query( const [result] = await pool.query(
"UPDATE users SET first_name = ?, last_name = ?, role = ?, email = ?, is_admin = ?, entry_updated_at = NOW() WHERE id = ?", "UPDATE users SET first_name = ?, last_name = ?, role = ?, email = ?, is_admin = ?, entry_updated_at = NOW() WHERE id = ? AND secret_user = false",
[first_name, last_name, role, email, is_admin, userId] [first_name, last_name, role, email, is_admin, userId],
); );
if (result.affectedRows > 0) return { success: true }; if (result.affectedRows > 0) return { success: true };
return { success: false }; return { success: false };
@@ -61,7 +61,7 @@ export const editUserById = async (
export const getAllUsers = async () => { export const getAllUsers = async () => {
const [result] = await pool.query( const [result] = await pool.query(
"SELECT id, username, first_name, last_name, role, email, is_admin, entry_created_at, entry_updated_at FROM users" "SELECT id, username, first_name, last_name, role, email, is_admin, entry_created_at, entry_updated_at FROM users WHERE secret_user = false",
); );
if (result.length > 0) return { success: true, data: result }; if (result.length > 0) return { success: true, data: result };
return { success: false }; return { success: false };
@@ -69,8 +69,8 @@ export const getAllUsers = async () => {
export const getUserById = async (userId) => { export const getUserById = async (userId) => {
const [rows] = await pool.query( const [rows] = await pool.query(
"SELECT id, username, first_name, last_name, role, email, is_admin FROM users WHERE id = ?", "SELECT id, username, first_name, last_name, role, email, is_admin FROM users WHERE id = ? AND secret_user = false",
[userId] [userId],
); );
if (rows.length === 0) { if (rows.length === 0) {
return { success: false }; return { success: false };
+5 -2
View File
@@ -41,11 +41,14 @@ router.post("/create-item", authenticateAdmin, async (req, res) => {
router.post("/edit-item/:id", authenticateAdmin, async (req, res) => { router.post("/edit-item/:id", authenticateAdmin, async (req, res) => {
const itemId = req.params.id; const itemId = req.params.id;
const { item_name, can_borrow_role } = req.body; const { item_name, can_borrow_role, safe_nr, door_key } = req.body;
const result = await editItemById( const result = await editItemById(
itemId, itemId,
item_name, item_name,
can_borrow_role can_borrow_role,
safe_nr,
door_key
); );
if (result.success) { if (result.success) {
return res.status(200).json({ message: "Item edited successfully" }); return res.status(200).json({ message: "Item edited successfully" });
+58 -27
View File
@@ -22,7 +22,7 @@ export const getItemsFromDatabaseV2 = async () => {
export const getLoanByCodeV2 = async (loan_code) => { export const getLoanByCodeV2 = async (loan_code) => {
const [result] = await pool.query( const [result] = await pool.query(
"SELECT username, returned_date, take_date, lockers FROM loans WHERE loan_code = ?;", "SELECT username, returned_date, take_date, lockers FROM loans WHERE loan_code = ?;",
[loan_code] [loan_code],
); );
if (result.length > 0) { if (result.length > 0) {
return { success: true, data: result[0] }; return { success: true, data: result[0] };
@@ -33,7 +33,7 @@ export const getLoanByCodeV2 = async (loan_code) => {
export const changeInSafeStateV2 = async (itemId) => { export const changeInSafeStateV2 = async (itemId) => {
const [result] = await pool.query( const [result] = await pool.query(
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?", "UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
[itemId] [itemId],
); );
if (result.affectedRows > 0) { if (result.affectedRows > 0) {
return { success: true }; return { success: true };
@@ -42,50 +42,62 @@ export const changeInSafeStateV2 = async (itemId) => {
}; };
export const setReturnDateV2 = async (loanCode) => { export const setReturnDateV2 = async (loanCode) => {
try {
const [items] = await pool.query( const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?", "SELECT loaned_items_id, username FROM loans WHERE loan_code = ?",
[loanCode] [loanCode],
); );
const [owner] = await pool.query( if (items.length === 0)
"SELECT username FROM loans WHERE loan_code = ?", return { success: false, message: "No items found for loan" };
[loanCode]
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id) const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id ? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]"); : JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET in_safe = 1, currently_borrowing = NULL, last_borrowed_person = (?) WHERE id IN (?)",
[owner[0].username, itemIds]
);
const [result] = await pool.query( const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?", "UPDATE loans SET returned_date = NOW() WHERE loan_code = ? AND returned_date IS NULL",
[loanCode] [loanCode],
); );
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) { if (result.affectedRows === 0) return { success: false };
return { success: true };
if (itemIds.length > 0) {
await pool.query(
"UPDATE items SET in_safe = 1, currently_borrowing = NULL, last_borrowed_person = ? WHERE id IN (?)",
[items[0].username, itemIds],
);
}
return { success: true, data: { returned: true } };
} catch (error) {
console.error("setReturnDateV2 error:", error);
return { success: false, message: "Failed to set return date" };
} }
return { success: false };
}; };
export const setTakeDateV2 = async (loanCode) => { export const setTakeDateV2 = async (loanCode) => {
const [isTaken] = await pool.query(
"SELECT take_date FROM loans WHERE loan_code = ?",
[loanCode],
);
if (isTaken.length === 0 || isTaken[0].take_date !== null) {
return { success: false, message: "Loan not found or already taken" };
}
const [items] = await pool.query( const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?", "SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode] [loanCode],
); );
const [owner] = await pool.query( const [owner] = await pool.query(
"SELECT username FROM loans WHERE loan_code = ?", "SELECT username FROM loans WHERE loan_code = ?",
[loanCode] [loanCode],
); );
if (items.length === 0) return { success: false }; if (items.length === 0)
return { success: false, message: "No items found for loan" };
const itemIds = Array.isArray(items[0].loaned_items_id) const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id ? items[0].loaned_items_id
@@ -93,18 +105,18 @@ export const setTakeDateV2 = async (loanCode) => {
const [setItemStates] = await pool.query( const [setItemStates] = await pool.query(
"UPDATE items SET in_safe = 0, currently_borrowing = (?) WHERE id IN (?)", "UPDATE items SET in_safe = 0, currently_borrowing = (?) WHERE id IN (?)",
[owner[0].username, itemIds] [owner[0].username, itemIds],
); );
const [result] = await pool.query( const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?", "UPDATE loans SET take_date = NOW() WHERE loan_code = ? AND take_date IS NULL",
[loanCode] [loanCode],
); );
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) { if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true }; return { success: true };
} }
return { success: false }; return { message: "Failed to set take date", success: false };
}; };
export const getAllLoansV2 = async () => { export const getAllLoansV2 = async () => {
@@ -114,3 +126,22 @@ export const getAllLoansV2 = async () => {
} }
return { success: false }; return { success: false };
}; };
export const openDoor = async (doorKey) => {
const [result] = await pool.query(
"SELECT safe_nr, id FROM items WHERE door_key = ?;",
[doorKey],
);
if (result.length > 0) {
const [changeItemSate] = await pool.query(
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
[result[0].id],
);
if (changeItemSate.affectedRows > 0) {
return { success: true, data: result[0] };
} else {
return { success: false };
}
}
return { success: false };
};
+19 -6
View File
@@ -10,6 +10,7 @@ import {
setTakeDateV2, setTakeDateV2,
setReturnDateV2, setReturnDateV2,
getLoanByCodeV2, getLoanByCodeV2,
openDoor,
} from "./api.database.js"; } from "./api.database.js";
// Route for API to get all items from the database // Route for API to get all items from the database
@@ -46,7 +47,7 @@ router.get(
} else { } else {
res.status(404).json({ message: "Loan not found" }); res.status(404).json({ message: "Loan not found" });
} }
} },
); );
// Route for API to set the return date by the loan code // Route for API to set the return date by the loan code
@@ -57,11 +58,11 @@ router.post(
const loanCode = req.params.loan_code; const loanCode = req.params.loan_code;
const result = await setReturnDateV2(loanCode); const result = await setReturnDateV2(loanCode);
if (result.success) { if (result.success) {
res.status(200).json({ data: result.data }); res.status(200).json({});
} else { } else {
res.status(500).json({ message: "Failed to set return date" }); res.status(500).json({ message: "Failed to set return date" });
} }
} },
); );
// Route for API to set the take away date by the loan code // Route for API to set the take away date by the loan code
@@ -72,11 +73,23 @@ router.post(
const loanCode = req.params.loan_code; const loanCode = req.params.loan_code;
const result = await setTakeDateV2(loanCode); const result = await setTakeDateV2(loanCode);
if (result.success) { if (result.success) {
res.status(200).json({ data: result.data }); res.status(200).json({});
} else { } else {
res.status(500).json({ message: "Failed to set take date" }); res.status(500).json({ message: result.message });
}
} }
},
); );
// Route for API to open a door
router.get("/open-door/:key/:doorKey", authenticate, async (req, res) => {
const doorKey = req.params.doorKey;
const result = await openDoor(doorKey);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to open door" });
}
});
export default router; export default router;
@@ -16,7 +16,7 @@ export const createLoanInDatabase = async (
startDate, startDate,
endDate, endDate,
note, note,
itemIds itemIds,
) => { ) => {
if (!username) if (!username)
return { success: false, code: "BAD_REQUEST", message: "Missing username" }; return { success: false, code: "BAD_REQUEST", message: "Missing username" };
@@ -52,7 +52,7 @@ export const createLoanInDatabase = async (
// Ensure all items exist and collect names + lockers // Ensure all items exist and collect names + lockers
const [itemsRows] = await conn.query( const [itemsRows] = await conn.query(
"SELECT id, item_name, safe_nr FROM items WHERE id IN (?)", "SELECT id, item_name, safe_nr FROM items WHERE id IN (?)",
[itemIds] [itemIds],
); );
if (!itemsRows || itemsRows.length !== itemIds.length) { if (!itemsRows || itemsRows.length !== itemIds.length) {
await conn.rollback(); await conn.rollback();
@@ -65,16 +65,24 @@ export const createLoanInDatabase = async (
const itemNames = itemIds const itemNames = itemIds
.map( .map(
(id) => itemsRows.find((r) => Number(r.id) === Number(id))?.item_name (id) => itemsRows.find((r) => Number(r.id) === Number(id))?.item_name,
) )
.filter(Boolean); .filter(Boolean);
// Build lockers array (unique, only 2-digit strings) // Build lockers array (unique, only 2-digit numbers from safe_nr)
const lockers = [ const lockers = [
...new Set( ...new Set(
itemsRows itemsRows
.map((r) => r.safe_nr) .map((r) => r.safe_nr)
.filter((sn) => typeof sn === "string" && /^\d{2}$/.test(sn)) .filter(
(sn) =>
sn !== null &&
sn !== undefined &&
Number.isInteger(Number(sn)) &&
Number(sn) >= 0 &&
Number(sn) <= 99,
)
.map((sn) => Number(sn)),
), ),
]; ];
@@ -90,7 +98,7 @@ export const createLoanInDatabase = async (
AND l.start_date < ? AND l.start_date < ?
AND COALESCE(l.returned_date, l.end_date) > ? AND COALESCE(l.returned_date, l.end_date) > ?
`, `,
[itemIds, end, start] [itemIds, end, start],
); );
if (confRows?.[0]?.conflicts > 0) { if (confRows?.[0]?.conflicts > 0) {
await conn.rollback(); await conn.rollback();
@@ -107,7 +115,7 @@ export const createLoanInDatabase = async (
const candidate = Math.floor(100000 + Math.random() * 899999); // 6 digits const candidate = Math.floor(100000 + Math.random() * 899999); // 6 digits
const [exists] = await conn.query( const [exists] = await conn.query(
"SELECT 1 FROM loans WHERE loan_code = ? LIMIT 1", "SELECT 1 FROM loans WHERE loan_code = ? LIMIT 1",
[candidate] [candidate],
); );
if (exists.length === 0) { if (exists.length === 0) {
loanCode = candidate; loanCode = candidate;
@@ -138,7 +146,7 @@ export const createLoanInDatabase = async (
JSON.stringify(itemIds.map((n) => Number(n))), JSON.stringify(itemIds.map((n) => Number(n))),
JSON.stringify(itemNames), JSON.stringify(itemNames),
note, note,
] ],
); );
await conn.commit(); await conn.commit();
@@ -181,7 +189,7 @@ export const getLoanInfoWithID = async (loanId) => {
export const getLoansFromDatabase = async (username) => { export const getLoansFromDatabase = async (username) => {
const [result] = await pool.query( const [result] = await pool.query(
"SELECT * FROM loans WHERE username = ? AND deleted = 0;", "SELECT * FROM loans WHERE username = ? AND deleted = 0;",
[username] [username],
); );
if (result.length > 0) { if (result.length > 0) {
return { success: true, status: true, data: result }; return { success: true, status: true, data: result };
@@ -194,7 +202,7 @@ export const getLoansFromDatabase = async (username) => {
export const getBorrowableItemsFromDatabase = async ( export const getBorrowableItemsFromDatabase = async (
startDate, startDate,
endDate, endDate,
role = 0 role = 0,
) => { ) => {
// Overlap if: loan.start < end AND effective_end > start // Overlap if: loan.start < end AND effective_end > start
// effective_end is returned_date if set, otherwise end_date // effective_end is returned_date if set, otherwise end_date
@@ -228,7 +236,7 @@ export const getBorrowableItemsFromDatabase = async (
export const SETdeleteLoanFromDatabase = async (loanId) => { export const SETdeleteLoanFromDatabase = async (loanId) => {
const [result] = await pool.query( const [result] = await pool.query(
"UPDATE loans SET deleted = 1 WHERE id = ?;", "UPDATE loans SET deleted = 1 WHERE id = ?;",
[loanId] [loanId],
); );
if (result.affectedRows > 0) { if (result.affectedRows > 0) {
return { success: true }; return { success: true };
@@ -252,3 +260,69 @@ export const getItems = async () => {
} }
return { success: false }; return { success: false };
}; };
export const setReturnDate = async (loanCode) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode],
);
const [owner] = await pool.query(
"SELECT username FROM loans WHERE loan_code = ?",
[loanCode],
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET in_safe = 1, currently_borrowing = NULL, last_borrowed_person = (?) WHERE id IN (?)",
[owner[0].username, itemIds],
);
const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
[loanCode],
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const setTakeDate = async (loanCode) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode],
);
const [owner] = await pool.query(
"SELECT username FROM loans WHERE loan_code = ?",
[loanCode],
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET in_safe = 0, currently_borrowing = (?) WHERE id IN (?)",
[owner[0].username, itemIds],
);
const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
[loanCode],
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
+26 -3
View File
@@ -13,6 +13,8 @@ import {
getALLLoans, getALLLoans,
getItems, getItems,
SETdeleteLoanFromDatabase, SETdeleteLoanFromDatabase,
setReturnDate,
setTakeDate,
} from "./database/loansMgmt.database.js"; } from "./database/loansMgmt.database.js";
import { sendMailLoan } from "./services/mailer.js"; import { sendMailLoan } from "./services/mailer.js";
@@ -48,7 +50,7 @@ router.post("/createLoan", authenticate, async (req, res) => {
start, start,
end, end,
note, note,
itemIds itemIds,
); );
if (result.success) { if (result.success) {
@@ -59,7 +61,8 @@ router.post("/createLoan", authenticate, async (req, res) => {
mailInfo.data.loaned_items_name, mailInfo.data.loaned_items_name,
mailInfo.data.start_date, mailInfo.data.start_date,
mailInfo.data.end_date, mailInfo.data.end_date,
mailInfo.data.created_at mailInfo.data.created_at,
mailInfo.data.note,
); );
return res.status(201).json({ return res.status(201).json({
message: "Loan created successfully", message: "Loan created successfully",
@@ -96,6 +99,26 @@ router.get("/loans", authenticate, async (req, res) => {
} }
}); });
router.post("/set-return-date/:loan_code", authenticate, async (req, res) => {
const loanCode = req.params.loan_code;
const result = await setReturnDate(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set return date" });
}
});
router.post("/set-take-date/:loan_code", authenticate, async (req, res) => {
const loanCode = req.params.loan_code;
const result = await setTakeDate(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set take date" });
}
});
router.get("/all-items", authenticate, async (req, res) => { router.get("/all-items", authenticate, async (req, res) => {
const result = await getItems(); const result = await getItems();
if (result.success) { if (result.success) {
@@ -135,7 +158,7 @@ router.post("/borrowable-items", authenticate, async (req, res) => {
const result = await getBorrowableItemsFromDatabase( const result = await getBorrowableItemsFromDatabase(
startDate, startDate,
endDate, endDate,
req.user.role req.user.role,
); );
if (result.success) { if (result.success) {
// return the array directly for consistency with /items // return the array directly for consistency with /items
+77 -10
View File
@@ -2,14 +2,53 @@ import nodemailer from "nodemailer";
import dotenv from "dotenv"; import dotenv from "dotenv";
dotenv.config(); dotenv.config();
function buildLoanEmail({ user, items, startDate, endDate, createdDate }) { const formatDateTime = (value) => {
if (value == null) return "N/A";
const toOut = (d) => {
if (!(d instanceof Date) || isNaN(d.getTime())) return "N/A";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${dd}.${mm}.${yyyy} ${hh}:${mi} Uhr`;
};
if (value instanceof Date) return toOut(value);
if (typeof value === "number") return toOut(new Date(value));
const s = String(value).trim();
// Direct pattern: "YYYY-MM-DD[ T]HH:mm[:ss]"
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::\d{2})?/);
if (m) {
const [, y, M, d, h, min] = m;
return `${d}.${M}.${y} ${h}:${min} Uhr`;
}
// ISO or other parseable formats
const dObj = new Date(s);
if (!isNaN(dObj.getTime())) return toOut(dObj);
return "N/A";
};
function buildLoanEmail({
user,
items,
startDate,
endDate,
createdDate,
note,
}) {
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9"; const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
const itemsList = const itemsList =
Array.isArray(items) && items.length Array.isArray(items) && items.length
? `<ul style="margin:4px 0 0 18px; padding:0;">${items ? `<ul style="margin:4px 0 0 18px; padding:0;">${items
.map( .map(
(i) => (i) =>
`<li style="margin:2px 0; color:#111827; line-height:1.3;">${i}</li>` `<li style="margin:2px 0; color:#111827; line-height:1.3;">${i}</li>`,
) )
.join("")}</ul>` .join("")}</ul>`
: "<span style='color:#111827;'>N/A</span>"; : "<span style='color:#111827;'>N/A</span>";
@@ -69,21 +108,27 @@ function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
<tr> <tr>
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Startdatum</td> <td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Startdatum</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime( <td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
startDate startDate,
)}</td> )}</td>
</tr> </tr>
<tr> <tr>
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Enddatum</td> <td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Enddatum</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime( <td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
endDate endDate,
)}</td> )}</td>
</tr> </tr>
<tr> <tr>
<td style="padding:10px 14px; color:#6b7280;">Erstellt am</td> <td style="padding:10px 14px; color:#6b7280;">Erstellt am</td>
<td style="padding:10px 14px; font-weight:600; color:#111827;">${formatDateTime( <td style="padding:10px 14px; font-weight:600; color:#111827;">${formatDateTime(
createdDate createdDate,
)}</td> )}</td>
</tr> </tr>
<tr>
<td style="padding:10px 14px; color:#6b7280; vertical-align:top;">Notiz</td>
<td style="padding:10px 14px; font-weight:600; color:#111827;">${
note || "Keine Notiz"
}</td>
</tr>
</tbody> </tbody>
</table> </table>
<p style="margin:22px 0 0 0; font-size:14px;"> <p style="margin:22px 0 0 0; font-size:14px;">
@@ -102,7 +147,14 @@ function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
</html>`; </html>`;
} }
function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) { function buildLoanEmailText({
user,
items,
startDate,
endDate,
createdDate,
note,
}) {
const itemsText = const itemsText =
Array.isArray(items) && items.length ? items.join(", ") : "N/A"; Array.isArray(items) && items.length ? items.join(", ") : "N/A";
return [ return [
@@ -113,10 +165,18 @@ function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
`Start: ${formatDateTime(startDate)}`, `Start: ${formatDateTime(startDate)}`,
`Ende: ${formatDateTime(endDate)}`, `Ende: ${formatDateTime(endDate)}`,
`Erstellt am: ${formatDateTime(createdDate)}`, `Erstellt am: ${formatDateTime(createdDate)}`,
`Notiz: ${note || "Keine Notiz"}`,
].join("\n"); ].join("\n");
} }
export function sendMailLoan(user, items, startDate, endDate, createdDate) { export function sendMailLoan(
user,
items,
startDate,
endDate,
createdDate,
note,
) {
const transporter = nodemailer.createTransport({ const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST, host: process.env.MAIL_HOST,
port: process.env.MAIL_PORT, port: process.env.MAIL_PORT,
@@ -138,11 +198,18 @@ export function sendMailLoan(user, items, startDate, endDate, createdDate) {
startDate, startDate,
endDate, endDate,
createdDate, createdDate,
note,
}),
html: buildLoanEmail({
user,
items,
startDate,
endDate,
createdDate,
note,
}), }),
html: buildLoanEmail({ user, items, startDate, endDate, createdDate }),
}); });
console.log("Message sent:", info.messageId); console.log("Loan message sent:", info.messageId);
})(); })();
console.log("sendMailLoan called");
} }
@@ -0,0 +1,43 @@
import nodemailer from "nodemailer";
import dotenv from "dotenv";
dotenv.config();
export function sendMail(username, message) {
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST,
port: process.env.MAIL_PORT,
secure: true,
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASSWORD,
},
});
(async () => {
const mailText = `Neue Kontaktanfrage im Ausleihsystem.\n\nBenutzername: ${username}\n\nNachricht:\n${message}`;
const mailHtml = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Neue Nachricht im Ausleihsystem</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.5; color: #222;">
<h2>Neue Nachricht im Ausleihsystem</h2>
<p><strong>Benutzername:</strong> ${username}</p>
<p><strong>Nachricht:</strong></p>
<p style="white-space: pre-line;">${message}</p>
</body>
</html>`;
const info = await transporter.sendMail({
from: '"Ausleihsystem" <noreply@mcs-medien.de>',
to: process.env.MAIL_SENDEES_CONTACT,
subject: "Sie haben eine neue Nachricht!",
text: mailText,
html: mailHtml,
});
console.log("Contact message sent: %s", info.messageId);
})();
}
+10
View File
@@ -6,6 +6,7 @@ dotenv.config();
// database funcs import // database funcs import
import { loginFunc, changePassword } from "./database/userMgmt.database.js"; import { loginFunc, changePassword } from "./database/userMgmt.database.js";
import { sendMail } from "./services/mailer_v2.js";
router.post("/login", async (req, res) => { router.post("/login", async (req, res) => {
const result = await loginFunc(req.body.username, req.body.password); const result = await loginFunc(req.body.username, req.body.password);
@@ -35,4 +36,13 @@ router.post("/change-password", authenticate, async (req, res) => {
} }
}); });
router.post("/contact", authenticate, async (req, res) => {
const message = req.body.message;
const username = req.user.username;
sendMail(username, message);
res.status(200).json({ message: "Contact message sent successfully" });
});
export default router; export default router;
Binary file not shown.
+100
View File
@@ -0,0 +1,100 @@
USE borrow_system_new;
-- USERS
INSERT INTO users (username, password, email, first_name, last_name, role, is_admin)
VALUES
('user1', 'passwordhash1', 'user1@example.com', 'First1', 'Last1', 1, false),
('user2', 'passwordhash2', 'user2@example.com', 'First2', 'Last2', 1, false),
('user3', 'passwordhash3', 'user3@example.com', 'First3', 'Last3', 2, false),
('admin1', 'passwordhash4', 'admin1@example.com', 'Admin', 'One', 9, true),
('admin2', 'passwordhash5', 'admin2@example.com', 'Admin', 'Two', 9, true);
-- ITEMS
INSERT INTO items (item_name, can_borrow_role, in_safe, safe_nr, door_key, last_borrowed_person, currently_borrowing)
VALUES
('Item1', 1, true, 1, 101, NULL, NULL),
('Item2', 1, true, 2, 102, 'user1', 'user1'),
('Item3', 2, true, 3, 103, 'user2', NULL),
('Item4', 1, false, NULL, NULL, NULL, NULL),
('Item5', 2, false, NULL, NULL, 'user3', 'user3');
-- LOANS
INSERT INTO loans (
username,
lockers,
loan_code,
start_date,
end_date,
take_date,
returned_date,
created_at,
loaned_items_id,
loaned_items_name,
deleted,
note
)
VALUES
(
'user1',
JSON_ARRAY('Locker1', 'Locker2'),
'123456',
'2026-02-01 09:00:00',
'2026-02-10 17:00:00',
'2026-02-01 09:15:00',
NULL,
'2026-02-01 09:00:00',
JSON_ARRAY(1, 2),
JSON_ARRAY('Item1', 'Item2'),
false,
'Erste allgemeine Ausleihe'
),
(
'user2',
JSON_ARRAY('Locker3'),
'234567',
'2026-02-02 10:00:00',
'2026-02-05 16:00:00',
'2026-02-02 10:05:00',
'2026-02-05 15:30:00',
'2026-02-02 10:00:00',
JSON_ARRAY(3),
JSON_ARRAY('Item3'),
false,
'Zurückgegeben vor Enddatum'
),
(
'user3',
JSON_ARRAY(),
'345678',
'2026-02-03 08:30:00',
'2026-02-15 18:00:00',
NULL,
NULL,
'2026-02-03 08:30:00',
JSON_ARRAY(5),
JSON_ARRAY('Item5'),
false,
'Noch ausgeliehen'
),
(
'user1',
JSON_ARRAY('Locker4'),
'456789',
'2025-12-01 09:00:00',
'2025-12-03 17:00:00',
'2025-12-01 09:10:00',
'2025-12-03 16:45:00',
'2025-12-01 09:00:00',
JSON_ARRAY(1),
JSON_ARRAY('Item1'),
true,
'Alte, gelöschte Ausleihe'
);
-- API KEYS
INSERT INTO apiKeys (api_key, entry_name)
VALUES
('10000001', 'Entry1'),
('10000002', 'Entry2'),
('10000003', 'Entry3'),
('10000004', 'Entry4');
-120
View File
@@ -1,120 +0,0 @@
USE borrow_system_new;
-- Reset tables (no FKs defined, so order is safe)
SET FOREIGN_KEY_CHECKS = 0;
TRUNCATE TABLE loans;
TRUNCATE TABLE apiKeys;
TRUNCATE TABLE items;
TRUNCATE TABLE users;
SET FOREIGN_KEY_CHECKS = 1;
-- Users (roles 16, plain-text passwords; is_admin is BOOL)
INSERT INTO users (username, password, email, first_name, last_name, role, is_admin) VALUES
('admin', 'adminpass', 'admin@example.com', 'System', 'Admin', 6, TRUE),
('alice', 'alice123', 'alice@example.com', 'Alice', 'Andersen',1, FALSE),
('bob', 'bob12345', 'bob@example.com', 'Bob', 'Berg', 2, FALSE),
('carol', 'carol123', 'carol@example.com', 'Carol', 'Christensen', 3, FALSE),
('dave', 'dave123', 'dave@example.com', 'Dave', 'Dahl', 4, FALSE),
('erin', 'erin123', 'erin@example.com', 'Erin', 'Enevoldsen', 5, FALSE),
('frank', 'frank123', 'frank@example.com', 'Frank', 'Fisher', 2, FALSE),
('grace', 'grace123', 'grace@example.com', 'Grace', 'Gundersen',1, FALSE),
('heidi', 'heidi123', 'heidi@example.com', 'Heidi', 'Hansen', 4, FALSE),
('tech', 'techpass', 'tech@example.com', 'Tech', 'User', 5, TRUE);
-- Items (safe_nr is two digits or NULL; matches CHECK and UNIQUE constraint)
INSERT INTO items (item_name, can_borrow_role, in_safe, safe_nr, last_borrowed_person, currently_borrowing) VALUES
('Laptop A', 2, FALSE, NULL, 'grace', 'bob'),
('Laptop B', 2, TRUE, '01', NULL, NULL),
('Camera Canon', 3, TRUE, '02', 'erin', NULL),
('Microphone Rode', 1, TRUE, '03', 'grace', NULL),
('Tripod Manfrotto', 1, TRUE, '04', 'frank', NULL),
('Oscilloscope Tek', 4, TRUE, '05', NULL, NULL),
('VR Headset', 3, FALSE, NULL, 'heidi', 'carol'),
('Keycard Programmer', 6, TRUE, '06', 'admin', NULL);
-- Loans (JSON strings, 6-digit numeric loan_code per CHECK)
-- Assumes the items above have ids 1..8 in insert order
INSERT INTO loans (
username,
lockers,
loan_code,
start_date,
end_date,
take_date,
returned_date,
loaned_items_id,
loaned_items_name,
deleted,
note
) VALUES
-- Active loan: bob has Laptop A (item id 1, locker "01")
('bob',
'["01"]',
'123456',
'2025-11-15 09:00:00',
'2025-11-22 17:00:00',
'2025-11-15 09:15:00',
NULL,
'[1]',
'["Laptop A"]',
FALSE,
'Active loan - Laptop A'
),
-- Returned loan: frank had Tripod Manfrotto (item id 5, locker "04")
('frank',
'["04"]',
'234567',
'2025-10-01 10:00:00',
'2025-10-07 16:00:00',
'2025-10-01 10:05:00',
'2025-10-05 15:30:00',
'[5]',
'["Tripod Manfrotto"]',
FALSE,
'Completed loan'
),
-- Future reservation: dave will take Oscilloscope Tek (item id 6, locker "05")
('dave',
'["05"]',
'345678',
'2025-12-10 09:00:00',
'2025-12-12 17:00:00',
NULL,
NULL,
'[6]',
'["Oscilloscope Tek"]',
FALSE,
'Reserved'
),
-- Active loan: carol has VR Headset (item id 7, locker "02")
('carol',
'["02"]',
'456789',
'2025-11-10 13:00:00',
'2025-11-20 12:00:00',
'2025-11-10 13:10:00',
NULL,
'[7]',
'["VR Headset"]',
FALSE,
'Active loan - VR Headset'
),
-- Soft-deleted historic loan: grace had Microphone + Tripod (item ids 4,5; lockers "03","04")
('grace',
'["03","04"]',
'567890',
'2025-09-01 09:00:00',
'2025-09-03 17:00:00',
'2025-09-01 09:10:00',
'2025-09-03 16:45:00',
'[4,5]',
'["Microphone Rode","Tripod Manfrotto"]',
TRUE,
'Canceled/soft-deleted record'
);
-- API keys (8-digit numeric keys per CHECK)
INSERT INTO apiKeys (api_key, entry_name, last_used_at) VALUES
('12345678', 'CI token', '2025-11-15 08:00:00'),
('87654321', 'Local dev', NULL),
('00000001', 'Monitoring', '2025-11-10 12:30:00');
+4 -3
View File
@@ -11,6 +11,7 @@ CREATE TABLE users (
is_admin bool NOT NULL DEFAULT false, is_admin bool NOT NULL DEFAULT false,
entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
secret_user bool NOT NULL DEFAULT false,
PRIMARY KEY (id) PRIMARY KEY (id)
) ENGINE=InnoDB; ) ENGINE=InnoDB;
@@ -37,13 +38,13 @@ CREATE TABLE items (
item_name varchar(255) NOT NULL UNIQUE, item_name varchar(255) NOT NULL UNIQUE,
can_borrow_role INT NOT NULL, can_borrow_role INT NOT NULL,
in_safe bool NOT NULL DEFAULT true, in_safe bool NOT NULL DEFAULT true,
safe_nr CHAR(2) DEFAULT NULL UNIQUE, safe_nr INT DEFAULT NULL UNIQUE,
door_key INT DEFAULT NULL UNIQUE,
entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_borrowed_person varchar(255) DEFAULT NULL, last_borrowed_person varchar(255) DEFAULT NULL,
currently_borrowing varchar(255) DEFAULT NULL, currently_borrowing varchar(255) DEFAULT NULL,
PRIMARY KEY (id), PRIMARY KEY (id)
CHECK (safe_nr REGEXP '^[0-9]{2}$' OR safe_nr IS NULL)
) ENGINE=InnoDB; ) ENGINE=InnoDB;
CREATE TABLE apiKeys ( CREATE TABLE apiKeys (
+1 -1
View File
@@ -20,7 +20,7 @@ import apiRouter from "./routes/api/api.route.js";
env.config(); env.config();
const app = express(); const app = express();
const port = 8004; const port = 8102;
app.use(cors()); app.use(cors());
// Body-Parser VOR den Routen registrieren // Body-Parser VOR den Routen registrieren
+24 -20
View File
@@ -1,35 +1,37 @@
services: services:
usr-frontend_v2: demo_usr_frontend:
container_name: borrow_system-usr-frontend container_name: demo_borrow_system-usr-frontend
networks:
- proxynet
build: ./FrontendV2 build: ./FrontendV2
ports:
- "8101:80"
restart: unless-stopped restart: unless-stopped
admin-frontend: demo_admin_frontend:
container_name: borrow_system-admin-frontend container_name: demo_borrow_system-admin-frontend
networks:
- proxynet
build: ./admin build: ./admin
ports:
- "8103:80"
restart: unless-stopped restart: unless-stopped
backend_v2: demo_backend_v2:
container_name: borrow_system-backend_v2 container_name: demo_borrow_system-backend_v2
networks:
- proxynet
build: ./backendV2 build: ./backendV2
ports:
- "8102:8102"
environment: environment:
NODE_ENV: production NODE_ENV: production
DB_HOST: mysql_v2 DB_HOST: demo_mysql_v2
DB_USER: root DB_USER: root
DB_PASSWORD: ${DB_PASSWORD_V2} DB_PASSWORD: ${DB_PASSWORD_V2}
DB_NAME: borrow_system_new DB_NAME: borrow_system_new
depends_on: depends_on:
- mysql_v2 - demo_mysql_v2
restart: unless-stopped restart: unless-stopped
mysql_v2: demo_mysql_v2:
container_name: borrow_system-mysql-v2 container_name: demo_borrow_system-mysql-v2
networks:
- proxynet
image: mysql:8.0 image: mysql:8.0
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -37,11 +39,13 @@ services:
MYSQL_DATABASE: borrow_system_new MYSQL_DATABASE: borrow_system_new
TZ: Europe/Berlin TZ: Europe/Berlin
volumes: volumes:
- mysql-v2-data:/var/lib/mysql - demo_mysql-v2-data:/var/lib/mysql
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro - ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
ports:
- "3310:3306"
volumes: volumes:
mysql-data: mysql-data:
mysql-v2-data: demo_mysql-v2-data:
networks:
proxynet:
external: true