Compare commits

..

90 Commits

Author SHA1 Message Date
theis.gaedigk 3ff31ecbf6 Merge branch 'dev' into host 2026-05-09 11:39:26 +02:00
theis.gaedigk c48da71cd5 improved translation 2026-05-09 11:37:44 +02:00
theis.gaedigk 8e35e81e8f Fixed bug: #16 2026-05-09 11:35:13 +02:00
theis.gaedigk 95aae1c050 cleaned changelog 2026-05-01 13:41:30 +02:00
theis.gaedigk 2cc5545ea8 fixed version info 2026-05-01 13:15:28 +02:00
theis.gaedigk 3f910f937b edited port on nginx config 2026-05-01 12:55:29 +02:00
theis.gaedigk 3ed6121a0b fixed version infos 2026-05-01 12:47:21 +02:00
theis.gaedigk 5d9e965597 fixed docker compose 2026-05-01 12:39:32 +02:00
theis.gaedigk e296de27ef Merge branch 'dev' into host 2026-05-01 12:35:45 +02:00
theis.gaedigk f8e29dca10 improved loan tabel on admin panel 2026-04-26 22:21:00 +02:00
theis.gaedigk e52fc13da4 updated changelog 2026-04-26 22:09:19 +02:00
theis.gaedigk 4291552b6d edited mailer and changed orchestration
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 21:52:35 +02:00
theis.gaedigk 5191871681 added feature: That no loan can be deleted and edited changelog accordingly 2026-04-26 19:35:48 +02:00
theis.gaedigk c61a283127 edited changelog and edited version numbers 2026-04-26 16:55:03 +02:00
theis.gaedigk 4a3c948386 implemented deactivated services banner
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 16:49:32 +02:00
theis.gaedigk 6fb03530df improved error logging
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 16:24:11 +02:00
theis.gaedigk d2c36e71be added request limiter to backend 2026-04-26 16:10:34 +02:00
theis.gaedigk 40d784ab36 implemented service configuration to admin panel
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 15:59:57 +02:00
theis.gaedigk 60c85efd37 refactored backend
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 15:03:37 +02:00
theis.gaedigk 747932cf03 implemented service configuration to API service
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 14:57:59 +02:00
theis.gaedigk 0964109c4b added new feature: service config; Currently implemented in: loanMgmt and userMgmt (only Backend)
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 14:40:53 +02:00
theis.gaedigk 5442f2f1f3 new feature: Error code 507 will return if you want to delete a loan that has not been returned 2026-04-24 18:44:07 +02:00
theis.gaedigk 56e073244f edited version numbers 2026-04-21 21:54:09 +02:00
theis.gaedigk d2dc74971f edited changelog 2026-04-21 21:53:16 +02:00
theis.gaedigk 25709ea0d9 implemented naas in to user frontend 2026-04-21 21:49:54 +02:00
theis.gaedigk f8ab2490fe added no-as-a-service project as a submodule 2026-04-21 20:48:57 +02:00
theis.gaedigk 3de877dd2b added icon to header and updated changelog 2026-04-19 22:04:49 +02:00
theis.gaedigk 5d0134017a added changelog and edited version numbers 2026-04-19 21:47:53 +02:00
theis.gaedigk 07503ec079 added animations to borrow-system 2026-04-19 21:32:56 +02:00
theis.gaedigk 5c7a96912b Merge branch 'dev' into debian12 2026-04-18 15:00:51 +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 195f270064 Merge branch 'dev' into debian12 2026-02-22 23:46:35 +01:00
theis.gaedigk c8a230979f changed version info 2026-02-22 23:45:38 +01:00
theis.gaedigk 3d592c5c76 Merge branch 'dev' into debian12 2026-02-22 23:44:46 +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 08104d32db Merge branch 'dev' into debian12 2026-02-20 16:30:34 +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 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 cc7c024892 Merge branch 'dev' into debian12 2026-02-07 17:48:44 +01:00
theis.gaedigk 3f59ed6951 removed line 2026-02-07 17:48:23 +01:00
theis.gaedigk 3eb452aeab fixed version 2026-02-07 17:41:09 +01:00
theis.gaedigk f46a654184 Merge branch 'dev' into debian12 2026-02-07 17:40:47 +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 863409aed9 Merge branch 'dev' into debian12 2026-02-04 13:47:04 +01:00
theis.gaedigk 7221ee1843 fixed message bug 2026-02-04 13:46:52 +01:00
theis.gaedigk 052137a697 removed ports 2026-02-01 15:50:52 +01:00
theis.gaedigk 2f3583ccd0 Merge branch 'dev' into debian12 2026-01-28 18:26:08 +01:00
theis.gaedigk 9da72cc5bf Merge branch 'dev' into debian12 2026-01-28 13:06:19 +01:00
theis.gaedigk c633627b7c Merge branch 'dev' into debian12 2026-01-28 12:44:25 +01:00
theis.gaedigk 5259c41b13 Merge branch 'dev' into debian12 2026-01-27 21:29:08 +01:00
theis.gaedigk 3d9e3814fe edited docker 2026-01-27 10:33:32 +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 1406f28f86 Merge branch 'dev' into debian12 2026-01-07 15:06:51 +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 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 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 f2bb326040 Merge branch 'dev' into debian12 2025-11-23 21:40:11 +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 dab004a7b6 changed docker config 2025-11-23 20:26:27 +01:00
theis.gaedigk d039336f39 Merge branch 'dev' into debian12 2025-11-23 20:20:41 +01:00
theis.gaedigk 4c781e9325 changed ports 2025-11-23 20:12:41 +01:00
theis.gaedigk 451e6b3646 published v2 2025-11-23 20:11:36 +01:00
70 changed files with 2720 additions and 2026 deletions
+6 -1
View File
@@ -116,4 +116,9 @@ ToDo.txt
# only in development branch
next-env.d.ts
next-env.d.ts
# psd files from footage
footage/*.psd
icon/
+3
View File
@@ -0,0 +1,3 @@
[submodule "no-as-a-service"]
path = no-as-a-service
url = https://github.com/hotheadhacker/no-as-a-service.git
+69 -258
View File
@@ -1,366 +1,177 @@
# Borrow System API Documentation
**Frontend:** https://insta.the1s.de
**Backend base URL:** `https://insta.the1s.de/backend/api`
## 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.
## Authentication
All API endpoints require **either**:
### 1. Bearer Token (JWT)
Send an `Authorization` header:
```http
Authorization: Bearer <JWT_TOKEN>
```
- Used for user-based access.
- Token must be valid and not expired.
### 2. API Key (for devices / machine-to-machine)
Include an API key in the route as `:key` parameter:
```text
/api/.../:key/...
```
Example:
```http
GET /api/items/12345678
```
Where `12345678` is your API key.
The API key is validated server-side.
---
## Common Response Codes
- `200 OK` Request was successful.
- `401 Unauthorized` Missing or malformed credentials.
- `403 Forbidden` Credentials invalid or not allowed to access this resource.
- `404 Not Found` Resource (e.g., loan) not found.
- `500 Internal Server Error` Unexpected server error.
---
All requests must include a valid API key in the URL path as the `:key` parameter. API keys are 8-digit numeric strings.
## Endpoints
### 1. Get All Items
The Base URL for all endpoints is: `https://insta.the1s.de/backend/api`
**GET** `/api/items/:key`
### Get All Items
Returns a list of all items.
`GET /items/:key`
#### Path Parameters
Returns all items in the system.
- `:key` API key (8-digit number)
#### Authentication
- Either:
- Valid `Authorization: Bearer <token>`
- Or valid `:key` path parameter
#### Request Example
```http
GET /api/items/12345678 HTTP/1.1
Host: backend.insta.the1s.de
Authorization: Bearer <JWT_TOKEN>
```
#### Successful Response (200)
**Response 200:**
```json
{
"data": [
{
"id": 1,
"item_name": "DJI 1er Mikro",
"can_borrow_role": 4,
"inSafe": 1,
"item_name": "Laptop",
"can_borrow_role": 1,
"in_safe": true,
"safe_nr": 3,
"door_key": "123",
"entry_created_at": "2025-08-19T22:02:16.000Z",
"entry_updated_at": "2025-08-19T22:02:16.000Z",
"last_borrowed_person": "alice",
"door_key": 101,
"last_borrowed_person": "jdoe",
"currently_borrowing": null
}
]
}
```
#### Error Response (500)
**Response 500:**
```json
{
"message": "Failed to fetch items"
}
{ "message": "Failed to fetch items" }
```
---
### 2. Toggle Item Safe State
### Change Item Safe State
Toggles `in_safe` between `0` and `1` for a given item.
`POST /change-state/:key/:itemId`
**Keep in mind that when you return a loan by code, the item states are automatically updated.**
Toggles the `in_safe` boolean state of an item.
**POST** `/api/change-state/:key/:itemId`
**URL Parameters:**
#### Path Parameters
- **key** - API key
- **itemId** - The item's ID
- `:key` API key (8-digit number)
- `:itemId` Item ID (integer)
**Response 200:** Returns on successful toggle.
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
POST /api/change-state/12345678/42 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
**Response 500:**
```json
{
"data": {}
}
```
_(Implementation currently only returns `{ success: true }`, so `data` may be empty.)_
#### Error Response (500)
```json
{
"message": "Failed to update item state"
}
{ "message": "Failed to update item state" }
```
---
### 3. Get Loan by Code
### Get Loan by Code
Fetch loan information by `loan_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 (8-digit number)
- `:loan_code` Loan code (string)
- **key** - API key
- **loan_code** - A 6-digit numeric loan code
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
GET /api/get-loan-by-code/12345678/12345 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
**Response 200:**
```json
{
"data": {
"username": "john",
"username": "jdoe",
"returned_date": null,
"take_date": "2025-01-01T10:00:00.000Z",
"lockers": "[1, 2, 3]"
"take_date": "2024-01-15T10:30:00.000Z",
"lockers": [1, 3]
}
}
```
#### Error Response (404)
**Response 404:**
```json
{
"message": "Loan not found"
}
{ "message": "Loan not found" }
```
---
### 4. Set Loan Return Date
### Set Take Date
Sets `returned_date = NOW()` on a loan and updates related items:
`POST /set-take-date/:key/:loan_code`
- `in_safe = 1`
- `currently_borrowing = NULL`
- `last_borrowed_person = username`
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.
**POST** `/api/set-return-date/:key/:loan_code`
**URL Parameters:**
#### Path Parameters
- **key** - API key
- **loan_code** - A 6-digit numeric loan code
- `:key` API key (8-digit number)
- `:loan_code` Loan code (string)
**Response 200:** Empty JSON object on success.
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
POST /api/set-return-date/12345678/12345 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
**Response 500:**
```json
{
"data": {}
}
{ "message": "Loan not found or already taken" }
```
#### Error Response (500)
```json
{
"message": "Failed to set return date"
}
```
> **Note:** This endpoint will fail if the loan has already been taken or does not exist.
---
### 5. Set Loan Take Date
### Set Return Date
Sets `take_date = NOW()` on a loan and updates related items:
`POST /set-return-date/:key/:loan_code`
- `in_safe = 0`
- `currently_borrowing = username`
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.
**POST** `/api/set-take-date/:key/:loan_code`
**URL Parameters:**
#### Path Parameters
- **key** - API key
- **loan_code** - A 6-digit numeric loan code
- `:key` API key (8-digit number)
- `:loan_code` Loan code (string)
**Response 200:** Empty JSON object on success.
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
POST /api/set-take-date/12345678/LOAN-12345 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
**Response 500:**
```json
{
"data": {}
}
{ "message": "Failed to set return date" }
```
#### Error Response (500)
```json
{
"message": "Failed to set take date"
}
```
> **Note:** This endpoint will fail if the loan has already been returned (i.e., `returned_date` is not `NULL`).
---
### 6. Open Door by Door Key
### Open Door
Looks up an item by its `door_key`, toggles `in_safe`, and returns safe information.
`GET /open-door/:key/:doorKey`
**GET** `/api/open-door/:key/:doorKey`
Toggles the safe state of an item identified by its door key and returns the associated safe number.
#### Path Parameters
**URL Parameters:**
- `:key` API key (8-digit number)
- `:doorKey` Door key/token (string) used by hardware to identify the locker.
- **key** - API key
- **doorKey** - The door key identifier assigned to an item
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
GET /api/open-door/12345678/123 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
**Response 200:**
```json
{
"data": {
"safe_nr": 5,
"id": 42
"safe_nr": 3,
"id": 1
}
}
```
#### Error Response (500)
**Response 500:**
```json
{
"message": "Failed to open door"
}
{ "message": "Failed to open door" }
```
---
## Error Handling
## Authentication Error Messages
### Missing credentials
Status: `401`
```json
{
"message": "Unauthorized"
}
```
### Invalid JWT
Status: `403`
```json
{
"message": "Present token invalid"
}
```
### Invalid API Key
Status: `403`
```json
{
"message": "API Key invalid"
}
```
---
## Notes
- All responses are JSON.
- Time fields like `take_date` and `returned_date` are in the format returned by MySQL (usually ISO-like strings).
- `loaned_items_id` in the database is stored as a JSON array string (e.g. `"[1,2,3]"`) and is parsed internally; clients do not interact with this field directly via current endpoints.
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.
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

+5 -1
View File
@@ -2,7 +2,11 @@
<html lang="en">
<head>
<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" />
<title>Ausleihsystem</title>
</head>
+328 -260
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"private": true,
"version": "0.0.0",
"version": "v2.1.2 (dev)",
"type": "module",
"scripts": {
"dev": "vite",
@@ -12,6 +12,7 @@
"dependencies": {
"@chakra-ui/react": "^3.28.0",
"@emotion/react": "^11.14.0",
"@lottiefiles/dotlottie-react": "^0.19.0",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.90.5",
"i18next": "^25.6.0",
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 Landingpage from "./pages/Landingpage";
import { changeLanguage } from "i18next";
import { Box, Flex } from "@chakra-ui/react";
import { Flex } from "@chakra-ui/react";
import { Footer } from "./components/footer/Footer";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { API_BASE } from "@/config/api.config";
@@ -72,8 +72,8 @@ function App() {
return (
<QueryClientProvider client={queryClient}>
<Flex direction="column" minH="100vh">
<Box as="main" flex="1">
<Flex direction="column" minH="100dvh">
<Flex as="main" flex="1" direction="column">
<UserContext.Provider value={user}>
<BrowserRouter>
<Routes>
@@ -88,7 +88,7 @@ function App() {
</Routes>
</BrowserRouter>
</UserContext.Provider>
</Box>
</Flex>
<Footer />
</Flex>
</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>
);
}
@@ -0,0 +1,67 @@
import { Alert, Stack, VStack, Spinner, Text, Heading } from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { API_BASE } from "@/config/api.config";
import Cookies from "js-cookie";
import { useTranslation } from "react-i18next";
export const DeactivatedServices = () => {
const { t } = useTranslation();
const [deactivatedServices, setDeactivatedServices] = useState<
{ function_name: string }[]
>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchDeactivatedServices = async () => {
setIsLoading(true);
try {
const response = await fetch(
`${API_BASE}/api/users/deactivated-services`,
{
headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`,
},
},
);
if (response.ok) {
const data = await response.json();
setDeactivatedServices(data);
} else {
console.error("Failed to fetch deactivated services");
}
} catch (error) {
console.error("Error fetching deactivated services:", error);
}
setIsLoading(false);
};
fetchDeactivatedServices();
}, []);
return (
<>
{deactivatedServices.length >= 1 && (
<Stack gap="2">
<Heading size={"xl"}>{t("deactivated-services")}</Heading>
{isLoading && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">{t("loading")}</Text>
</VStack>
)}
{deactivatedServices.length >= 1 &&
deactivatedServices.map((item) => (
<Alert.Root key={item.function_name} status="warning">
<Alert.Indicator />
<Alert.Title>
{item.function_name} {t("is-deactivated")}
</Alert.Title>
</Alert.Root>
))}
</Stack>
)}
</>
);
};
+11 -2
View File
@@ -1,6 +1,7 @@
import {
Button,
Flex,
Image,
Heading,
Stack,
Text,
@@ -68,6 +69,7 @@ export const Header = () => {
className="mb-6"
position="relative"
pr={{ base: 10, md: 0 }} // Platz für den Mobile-Button rechts
marginBottom={1}
>
{/* Mobile: Drei-Punkte-Button, vertikal zentriert im Header */}
<Box
@@ -142,7 +144,7 @@ export const Header = () => {
value="help"
onSelect={() =>
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",
"noopener,noreferrer",
)
@@ -190,6 +192,13 @@ export const Header = () => {
<Stack gap={1}>
{/* Titelzeile ohne Mobile-Menu (wurde nach oben verlegt) */}
<Flex align="center" justify="space-between" gap={2}>
<Image
src="/icon_borrow-system-frontend_dark.png"
alt="borrow-system logo"
boxSize="10"
objectFit="contain"
flexShrink={0}
/>
<Heading
size="2xl"
className="tracking-tight text-slate-900 dark:text-slate-100"
@@ -279,7 +288,7 @@ export const Header = () => {
</Button>
<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"
>
<Button variant="ghost">
+78 -5
View File
@@ -36,12 +36,43 @@ export const UserDialogue = (props: UserDialogueProps) => {
const [msgTitle, setMsgTitle] = useState("");
const [msgDescription, setMsgDescription] = useState("");
const [isMsgNAAS, setIsMsgNAAS] = useState(false);
const [msgStatusNAAS, setMsgStatusNAAS] = useState<"error" | "success">(
"error",
);
const [msgTitleNAAS, setMsgTitleNAAS] = useState("");
const [msgDescriptionNAAS, setMsgDescriptionNAAS] = useState("");
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
// Dialog control
const [isPwOpen, setPwOpen] = useState(false);
const [naasDialog, setNaasDialog] = useState(false);
const [naas, setNaas] = useState("");
const openNAAS = async () => {
try {
const response = await fetch(`${API_BASE}/no`, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
const data = await response.json();
setNaas(data.reason);
setNaasDialog(true);
} catch (error) {
setMsgStatusNAAS("error");
setMsgTitleNAAS(t("naas-error"));
setMsgDescriptionNAAS(t("naas-error-desc"));
setIsMsgNAAS(true);
console.log(msgStatusNAAS, msgTitleNAAS, msgDescriptionNAAS);
}
};
const changePassword = async () => {
if (newPassword !== confirmPassword) {
@@ -147,14 +178,31 @@ export const UserDialogue = (props: UserDialogueProps) => {
</Button>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
<Button variant="outline" onClick={() => props.setUserDialog(false)}>
{t("cancel")}
</Button>
<Card.Footer>
<Stack w="100%" gap={3}>
{isMsgNAAS && (
<MyAlert
status={msgStatusNAAS}
title={msgTitleNAAS}
description={msgDescriptionNAAS}
/>
)}
<HStack justify="flex-end" gap={2} wrap="wrap">
<Button
variant="outline"
onClick={() => props.setUserDialog(false)}
>
{t("cancel")}
</Button>
<Button variant="outline" onClick={() => openNAAS()}>
{t("try-naas")}
</Button>
</HStack>
</Stack>
</Card.Footer>
</Card.Root>
{/* Passwort-Dialog (kontrolliert) */}
{/* Passwort-Dialog */}
<Dialog.Root open={isPwOpen} onOpenChange={(e: any) => setPwOpen(e.open)}>
<Portal>
<Dialog.Backdrop />
@@ -215,6 +263,31 @@ export const UserDialogue = (props: UserDialogueProps) => {
</Dialog.Positioner>
</Portal>
</Dialog.Root>
<HStack wrap="wrap" gap="4">
<Dialog.Root
placement={"center"}
open={naasDialog}
motionPreset="slide-in-bottom"
>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{t("naas-header")}</Dialog.Title>
</Dialog.Header>
<Dialog.Body>
<p>{naas}</p>
</Dialog.Body>
<Dialog.CloseTrigger asChild>
<CloseButton onClick={() => setNaasDialog(false)} size="sm" />
</Dialog.CloseTrigger>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
</HStack>
</Flex>
);
};
+28
View File
@@ -0,0 +1,28 @@
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
export const unlockAnimation = () => {
return (
<DotLottieReact
src="https://lottie.host/f839baa1-9c64-44c4-9386-f0e4c87ab208/2Iw1m4k86d.lottie"
autoplay
/>
);
};
export const approvalAnimation = () => {
return (
<DotLottieReact
src="https://lottie.host/b7257009-9e3f-43e2-8112-a176f4696e4c/iQxxqAVOGX.lottie"
autoplay
/>
);
};
export const logoutAnimation = () => {
return (
<DotLottieReact
src="https://lottie.host/4975758c-de38-4d15-9f74-927709751d32/v8FtKpnD1y.lottie"
autoplay
/>
);
};
+3 -4
View File
@@ -9,10 +9,9 @@ export const Footer = () => {
as="footer"
py={4}
textAlign="center"
position="fixed"
bottom="0"
left="0"
right="0"
width="100%"
flexShrink={0}
fontSize="sm"
>
Made with by Theis Gaedigk - Class of 2019 at MCS-Bochum
<br />
+17 -9
View File
@@ -1,15 +1,23 @@
"use client"
"use client";
import { ChakraProvider, defaultSystem } from "@chakra-ui/react"
import {
ColorModeProvider,
type ColorModeProviderProps,
} from "./color-mode"
import { ChakraProvider, defaultSystem } from "@chakra-ui/react";
import * as React from "react";
import type { ReactNode } from "react";
import { ColorModeProvider as ThemeColorModeProvider } 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 (
<ChakraProvider value={defaultSystem}>
<ColorModeProvider {...props} />
<ColorModeProvider>{children}</ColorModeProvider>
</ChakraProvider>
)
);
}
+36 -2
View File
@@ -1,4 +1,13 @@
import { Field, Textarea, Button, Alert, Container } from "@chakra-ui/react";
import {
Field,
Textarea,
Button,
Alert,
Container,
Text,
VStack,
Spinner,
} from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
import { API_BASE } from "@/config/api.config";
@@ -14,9 +23,21 @@ interface Alert {
export const ContactPage = () => {
const { t } = useTranslation();
const [message, setMessage] = useState("");
const [isSending, setIsSending] = useState(false);
const [alert, setAlert] = useState<Alert | null>(null);
const sendMessage = async () => {
setIsSending(true);
if (message.trim() === "") {
setAlert({
type: "error",
headline: t("contactPage_messageErrorHeadline"),
text: t("contactPage_messageErrorText2"),
});
setIsSending(false);
return;
}
// Logic to send the message
const result = await fetch(`${API_BASE}/api/users/contact`, {
method: "POST",
@@ -35,6 +56,12 @@ export const ContactPage = () => {
text: t("contactPage_successText"),
});
setMessage("");
} else if (result.status === 503) {
setAlert({
type: "error",
headline: t("serviceDeactivatedHeadline"),
text: t("contactPage_serviceDeactivatedText"),
});
} else {
setAlert({
type: "error",
@@ -42,6 +69,7 @@ export const ContactPage = () => {
text: t("contactPage_errorText"),
});
}
setIsSending(false);
};
return (
@@ -49,7 +77,7 @@ export const ContactPage = () => {
<Header />
<Field.Root invalid={message === ""}>
<Field.Label>
{t("contactPage_messageLabel")}
<Text>{t("contactPage_messageDescription")}</Text>
<Field.RequiredIndicator />
</Field.Label>
<Textarea
@@ -71,6 +99,12 @@ export const ContactPage = () => {
</Alert.Content>
</Alert.Root>
)}
{isSending && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">{t("loading")}</Text>
</VStack>
)}
<Button onClick={sendMessage}>{t("contactPage_sendButton")}</Button>
</Container>
);
+161 -137
View File
@@ -18,6 +18,8 @@ import { borrowAbleItemsAtom } from "@/states/Atoms";
import { createLoan } from "@/utils/Fetcher";
import { Header } from "@/components/Header";
import { useTranslation } from "react-i18next";
import { approvalAnimation } from "@/components/dotLottie";
import { DeactivatedServices } from "@/components/DeactivatedServices";
export interface User {
username: string;
@@ -27,6 +29,8 @@ export interface User {
export const HomePage = () => {
const { t } = useTranslation();
const [showAnimation, setShowAnimation] = useState(false);
const [borrowableItems, setBorrowableItems] = useAtom(borrowAbleItemsAtom);
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
@@ -46,155 +50,175 @@ export const HomePage = () => {
setSelectedItems((prevSelected) =>
prevSelected.includes(itemId)
? prevSelected.filter((id) => id !== itemId)
: [...prevSelected, itemId]
: [...prevSelected, itemId],
);
};
const showApprovalAnimation = (seconds: number) => {
const milliseconds = seconds * 1000;
setShowAnimation(true);
window.setTimeout(() => {
setShowAnimation(false);
}, milliseconds);
};
return (
<Container className="px-6 sm:px-8 pt-10">
<Header />
{isMsg && (
<MyAlert
status={msgStatus}
title={msgTitle}
description={msgDescription}
/>
<>
{showAnimation && (
<div className="fixed inset-0 z-9999 flex items-center justify-center pointer-events-none">
<div>{approvalAnimation()}</div>
</div>
)}
<Stack as="main">
<Text>{t("timezone-info")}</Text>
<label htmlFor="startDate">
<strong>
<Text>{t("start-date")}</Text>
</strong>
</label>
<Input
id="startDate"
placeholder={t("start-date")}
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<label htmlFor="endDate">
<strong>
<Text>{t("end-date")}</Text>
</strong>
</label>
<Input
id="endDate"
placeholder={t("end-date")}
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
<Button
onClick={async () => {
setIsLoadingA(true);
if (!startDate || !endDate) {
setMsgStatus("error");
setMsgTitle(t("missing-fields"));
setMsgDescription(t("missing-fields-desc"));
setIsMsg(true);
setIsLoadingA(false);
return;
}
await getBorrowableItems(startDate, endDate).then((response) => {
setIsLoadingA(false);
if (response && response.status === "error") {
<Container className="px-6 sm:px-8 pt-10">
<Header />
<DeactivatedServices />
{isMsg && (
<MyAlert
status={msgStatus}
title={msgTitle}
description={msgDescription}
/>
)}
<Stack as="main">
<Text>{t("timezone-info")}</Text>
<label htmlFor="startDate">
<strong>
<Text>{t("start-date")}</Text>
</strong>
</label>
<Input
id="startDate"
placeholder={t("start-date")}
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<label htmlFor="endDate">
<strong>
<Text>{t("end-date")}</Text>
</strong>
</label>
<Input
id="endDate"
placeholder={t("end-date")}
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
<Button
onClick={async () => {
setIsLoadingA(true);
if (!startDate || !endDate) {
setMsgStatus("error");
setMsgTitle(response.title || t("error"));
setMsgDescription(response.description || t("unknown-error"));
setMsgTitle(t("missing-fields"));
setMsgDescription(t("missing-fields-desc"));
setIsMsg(true);
setIsLoadingA(false);
return;
}
setBorrowableItems(response.data);
setIsMsg(false);
});
}}
>
{t("get-borrowable-items")}
</Button>
{isLoadingA && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">{t("loading")}</Text>
</VStack>
)}
{borrowableItems.length > 0 && (
<Table.ScrollArea borderWidth="1px" rounded="md">
<Table.Root size="sm" stickyHeader>
<Table.Header>
<Table.Row bg="bg.subtle">
<Table.ColumnHeader></Table.ColumnHeader>
<Table.ColumnHeader>{t("item")}</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{borrowableItems.map((item) => (
<Table.Row key={item.id}>
<Table.Cell>
<input
onChange={() => handleCheckboxChange(item.id)}
type="checkbox"
name={item.id}
id={item.id}
/>
</Table.Cell>
<Table.Cell>{item.item_name}</Table.Cell>
</Table.Row>
))}
<Table.Row>
<Table.Cell colSpan={2}>
<InputGroup
endElement={
<Span color="fg.muted" textStyle="xs">
{note.length} / {MAX_CHARACTERS}
</Span>
}
>
<Input
placeholder={t("optional-note")}
value={note}
maxLength={MAX_CHARACTERS}
onChange={(e) => {
setNote(
e.currentTarget.value.slice(0, MAX_CHARACTERS)
);
}}
/>
</InputGroup>
</Table.Cell>
</Table.Row>
</Table.Body>
</Table.Root>
</Table.ScrollArea>
)}
{selectedItems.length >= 1 && (
<Button
onClick={() =>
createLoan(selectedItems, startDate, endDate, note).then(
(response) => {
if (response.status === "error") {
setMsgStatus("error");
setMsgTitle(response.title || t("error"));
setMsgDescription(
response.description || t("unknown-error")
);
setIsMsg(true);
return;
}
setMsgStatus("success");
setMsgTitle(t("success"));
setMsgDescription(t("loan-success"));
await getBorrowableItems(startDate, endDate).then((response) => {
setIsLoadingA(false);
if (response && response.status === "error") {
setMsgStatus("error");
setMsgTitle(response.title || t("error"));
setMsgDescription(response.description || t("unknown-error"));
setIsMsg(true);
return;
}
)
}
setBorrowableItems(response.data);
setIsMsg(false);
});
}}
>
{t("create-loan")}
{t("get-borrowable-items")}
</Button>
)}
</Stack>
</Container>
{borrowableItems.length > 0 && (
<Table.ScrollArea borderWidth="1px" rounded="md">
<Table.Root size="sm" stickyHeader>
<Table.Header>
<Table.Row bg="bg.subtle">
<Table.ColumnHeader></Table.ColumnHeader>
<Table.ColumnHeader>{t("item")}</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{borrowableItems.map((item) => (
<Table.Row key={item.id}>
<Table.Cell>
<input
onChange={() => handleCheckboxChange(item.id)}
type="checkbox"
name={item.id}
id={item.id}
/>
</Table.Cell>
<Table.Cell>{item.item_name}</Table.Cell>
</Table.Row>
))}
<Table.Row>
<Table.Cell colSpan={2}>
<InputGroup
endElement={
<Span color="fg.muted" textStyle="xs">
{note.length} / {MAX_CHARACTERS}
</Span>
}
>
<Input
placeholder={t("optional-note")}
value={note}
maxLength={MAX_CHARACTERS}
onChange={(e) => {
setNote(
e.currentTarget.value.slice(0, MAX_CHARACTERS),
);
}}
/>
</InputGroup>
</Table.Cell>
</Table.Row>
</Table.Body>
</Table.Root>
</Table.ScrollArea>
)}
{selectedItems.length >= 1 && (
<Button
onClick={() => {
setIsLoadingA(true);
createLoan(selectedItems, startDate, endDate, note).then(
(response) => {
setIsLoadingA(false);
if (response.status === "error") {
setMsgStatus("error");
setMsgTitle(response.title || t("error"));
setMsgDescription(
response.description || t("unknown-error"),
);
setIsMsg(true);
return;
}
showApprovalAnimation(3);
setMsgStatus("success");
setMsgTitle(t("success"));
setMsgDescription(t("loan-success"));
setIsMsg(true);
},
);
}}
>
{t("create-loan")}
</Button>
)}
{isLoadingA && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">{t("loading")}</Text>
</VStack>
)}
</Stack>
</Container>
</>
);
};
+23 -14
View File
@@ -9,12 +9,13 @@ import {
Card,
SimpleGrid,
Button,
Container,
} from "@chakra-ui/react";
import MyAlert from "@/components/myChakra/MyAlert";
import { useTranslation } from "react-i18next";
import { API_BASE } from "@/config/api.config";
import Cookies from "js-cookie";
import { useNavigate } from "react-router-dom";
import { Header } from "@/components/Header";
export const formatDateTime = (value: string | null | undefined) => {
if (!value) return "N/A";
@@ -32,6 +33,7 @@ type Loan = {
returned_date: string | null;
take_date: string | null;
loaned_items_name: string[] | string;
note: string | null;
};
type Device = {
@@ -46,7 +48,6 @@ type Device = {
const Landingpage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [loans, setLoans] = useState<Loan[]>([]);
@@ -59,7 +60,7 @@ const Landingpage: React.FC = () => {
const setError = (
status: "error" | "success",
message: string,
description: string
description: string,
) => {
setIsError(false);
setErrorStatus(status);
@@ -78,6 +79,16 @@ const Landingpage: React.FC = () => {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
if (loanRes.status === 503) {
setError(
"error",
t("serviceDeactivatedHeadline"),
t("loan_page_serviceDeactivatedText"),
);
setIsLoading(false);
return;
}
const loanData = await loanRes.json();
if (Array.isArray(loanData)) {
setLoans(loanData);
@@ -85,7 +96,7 @@ const Landingpage: React.FC = () => {
setError(
"error",
t("error-by-loading"),
t("unexpected-date-format_loan")
t("unexpected-date-format_loan"),
);
}
@@ -102,7 +113,7 @@ const Landingpage: React.FC = () => {
setError(
"error",
t("error-by-loading"),
t("unexpected-date-format_device")
t("unexpected-date-format_device"),
);
}
} catch (e) {
@@ -115,14 +126,8 @@ const Landingpage: React.FC = () => {
}, []);
return (
<>
<Heading as="h1" size="lg" mb={2}>
Matthias-Claudius-Schule Technik
</Heading>
<Button onClick={() => navigate("/", { replace: true })}>
{t("back")}
</Button>
<Container className="px-6 sm:px-8 pt-10">
<Header />
<Heading as="h2" size="md" mb={4}>
{t("all-loans")}
@@ -168,6 +173,9 @@ const Landingpage: React.FC = () => {
<Table.ColumnHeader>
<strong>{t("return-date")}</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>{t("note")}</strong>
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
@@ -184,6 +192,7 @@ const Landingpage: React.FC = () => {
</Table.Cell>
<Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell>
<Table.Cell>{formatDateTime(loan.returned_date)}</Table.Cell>
<Table.Cell>{loan.note}</Table.Cell>
</Table.Row>
))}
</Table.Body>
@@ -260,7 +269,7 @@ const Landingpage: React.FC = () => {
</HStack>
</Button>
</HStack>
</>
</Container>
);
};
+93 -55
View File
@@ -4,27 +4,47 @@ import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms";
import { useAtom } from "jotai";
import Cookies from "js-cookie";
import { Navigate, useNavigate, useLocation } from "react-router-dom";
import { useNavigate, useLocation } from "react-router-dom";
import { PasswordInput } from "@/components/ui/password-input";
import { useTranslation } from "react-i18next";
import { Footer } from "@/components/footer/Footer";
import { API_BASE } from "@/config/api.config";
import { unlockAnimation } from "@/components/dotLottie";
import { logoutAnimation } from "@/components/dotLottie";
export const LoginPage = () => {
const { t } = useTranslation();
const [isLoggedIn, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
const [triggerLogout, setTriggerLogout] = useAtom(triggerLogoutAtom);
const [showAnimation, setShowAnimation] = useState(false);
const [showLogout, setShowLogout] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const from = location.state?.from?.pathname || "/";
useEffect(() => {
if (isLoggedIn) {
navigate(from, { replace: true });
window.location.reload(); // if deleted, the user context is not updated in time
if (triggerLogout) {
setShowLogout(true);
window.setTimeout(() => {
setShowLogout(false);
}, 4500);
}
}, [isLoggedIn, navigate, from]);
if (!isLoggedIn) return;
// Existing sessions should redirect immediately, fresh logins wait for animation.
if (!showAnimation) {
navigate(from, { replace: true });
return;
}
const timeoutId = window.setTimeout(() => {
navigate(from, { replace: true });
window.location.reload(); // keeps user context in sync after login
}, 3000);
return () => window.clearTimeout(timeoutId);
}, [isLoggedIn, showAnimation, navigate, from]);
const loginFnc = async (username: string, password: string) => {
const response = await fetch(`${API_BASE}/api/users/login`, {
@@ -43,6 +63,8 @@ export const LoginPage = () => {
};
}
setShowAnimation(true);
Cookies.set("token", data.token);
setIsLoggedIn(true);
return { success: true };
@@ -63,59 +85,75 @@ export const LoginPage = () => {
return;
}
setTriggerLogout(false);
navigate(from, { replace: true });
};
if (isLoggedIn) {
return <Navigate to={from} replace />;
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<form onSubmit={(e) => e.preventDefault()}>
<Card.Root maxW="sm">
<Card.Header>
<Card.Title>{t("login")}</Card.Title>
<Card.Description>{t("enter-credentials")}</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>{t("username")}</Field.Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
<>
{showAnimation && (
<div className="fixed inset-0 z-9999 flex items-center justify-center pointer-events-none">
<div>{unlockAnimation()}</div>
</div>
)}
{showLogout && (
<div className="fixed inset-0 z-9999 flex items-center justify-center pointer-events-none">
<div>{logoutAnimation()}</div>
</div>
)}
<div className="flex flex-1 items-center justify-center p-4">
<form onSubmit={(e) => e.preventDefault()}>
<Card.Root maxW="sm">
<Card.Header>
<Card.Title>{t("login")}</Card.Title>
<Card.Description>{t("enter-credentials")}</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>{t("username")}</Field.Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</Field.Root>
<Field.Root>
<Field.Label>{t("password")}</Field.Label>
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</Field.Root>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
{isError && (
<MyAlert
status="error"
title={errorMsg}
description={errorDsc}
/>
</Field.Root>
<Field.Root>
<Field.Label>{t("password")}</Field.Label>
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
)}
<Button
type="submit"
onClick={() => handleLogin()}
variant="solid"
>
Login
</Button>
</Card.Footer>
<Card.Footer justifyContent="flex-end">
{triggerLogout && (
<MyAlert
status="success"
title={t("logout-success")}
description={t("logout-success-desc")}
/>
</Field.Root>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
{isError && (
<MyAlert status="error" title={errorMsg} description={errorDsc} />
)}
<Button type="submit" onClick={() => handleLogin()} variant="solid">
Login
</Button>
</Card.Footer>
<Card.Footer justifyContent="flex-end">
{triggerLogout && (
<MyAlert
status="success"
title={t("logout-success")}
description={t("logout-success-desc")}
/>
)}
</Card.Footer>
</Card.Root>
</form>
<Footer />
</div>
)}
</Card.Footer>
</Card.Root>
</form>
</div>
</>
);
};
+39 -6
View File
@@ -52,6 +52,13 @@ export const MyLoansPage = () => {
});
if (!res.ok) {
if (res.status === 503) {
setMsgStatus("error");
setMsgTitle(t("serviceDeactivatedHeadline"));
setMsgDescription(t("loan_page_serviceDeactivatedText"));
setIsMsg(true);
return;
}
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("error-fetching-loans"));
@@ -84,6 +91,14 @@ export const MyLoansPage = () => {
});
if (!res.ok) {
if (res.status === 507) {
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("error-deleting-loan-507"));
setIsMsg(true);
return;
}
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("error-deleting-loan"));
@@ -106,10 +121,28 @@ export const MyLoansPage = () => {
const formatDate = (iso: string | null) => {
if (!iso) return "-";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!m) return iso;
const [, y, M, d, h, min] = m;
return `${d}.${M}.${y} ${h}:${min}`;
const date = new Date(iso);
if (isNaN(date.getTime())) return iso;
return date.toLocaleString("de-DE", {
timeZone: "Europe/Berlin",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const dateAndTime = (isISO: boolean) => {
const date = new Date();
if (isISO) {
return date.toISOString();
}
if (!isISO) {
return date;
}
};
const handleTakeAction = async (loanCode: string) => {
@@ -136,7 +169,7 @@ export const MyLoansPage = () => {
setLoans((prev) =>
prev.map((loan) =>
loan.loan_code === loanCode
? { ...loan, take_date: new Date().toISOString() }
? { ...loan, take_date: dateAndTime(true) }
: loan,
),
);
@@ -176,7 +209,7 @@ export const MyLoansPage = () => {
setLoans((prev) =>
prev.map((loan) =>
loan.loan_code === loanCode
? { ...loan, returned_date: new Date().toISOString() }
? { ...loan, returned_date: dateAndTime(true) }
: loan,
),
);
+23 -3
View File
@@ -3,7 +3,7 @@ import { API_BASE } from "@/config/api.config";
export const getBorrowableItems = async (
startDate: string,
endDate: string
endDate: string,
) => {
try {
const response = await fetch(`${API_BASE}/api/loans/borrowable-items`, {
@@ -17,12 +17,22 @@ export const getBorrowableItems = async (
});
if (!response.ok) {
if (response.status === 503) {
return {
data: null,
status: "error",
title: "Service deactivated",
description:
"The loan service is currently deactivated. Please try again later.",
};
}
return {
data: null,
status: "error",
title: "Server error",
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 +58,7 @@ export const createLoan = async (
itemIds: number[],
startDate: string,
endDate: string,
note: string | null
note: string | null,
) => {
const response = await fetch(`${API_BASE}/api/loans/createLoan`, {
method: "POST",
@@ -60,6 +70,16 @@ export const createLoan = async (
});
if (!response.ok) {
if (response.status === 503) {
return {
data: null,
status: "error",
title: "Service deactivated",
description:
"The loan service is currently deactivated. Please try again later.",
};
}
return {
data: null,
status: "error",
+17 -2
View File
@@ -60,7 +60,7 @@
"sure-delete-loan-2": "Für den Admin bleibt sie weiterhin sichtbar.",
"delete": "Löschen",
"change-language": "Sprache ändern",
"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. Das gesamte System ist auf die deutsche Zeitzone eingestellt.",
"optional-note": "Optionale Notiz",
"note": "Notiz",
"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.",
@@ -84,7 +84,22 @@
"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."
"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.",
"naas": "No-as-a-service",
"try-naas": "Klick mich",
"naas-error": "Fehler mit no-as-a-service",
"naas-error-desc": "Ein Fehler ist beim Kommunizieren mit no-as-a-service aufgetreten.",
"naas-header": "Eine gute Möglichkeit, nein zu sagen...",
"error-deleting-loan-507": "Die Ausleihe kann nicht gelöscht werden, da sie noch nicht zurückgegeben wurde.",
"serviceDeactivatedHeadline": "Service deaktiviert",
"contactPage_serviceDeactivatedText": "Der Kontaktservice ist derzeit deaktiviert. Bitte versuchen Sie es später erneut.",
"loan_page_serviceDeactivatedText": "Der Ausleihservice ist derzeit deaktiviert. Bitte versuchen Sie es später erneut.",
"is-deactivated": "ist deaktiviert.",
"deactivated-services": "Deaktivierte Services",
"contactPage_messageErrorHeadline": "Fehler bei der Nachrichteneingabe",
"contactPage_messageErrorText2": "Bitte geben Sie eine Nachricht ein, bevor Sie sie senden."
}
+22 -2
View File
@@ -60,7 +60,7 @@
"sure-delete-loan-2": "It will remain visible to the admin.",
"delete": "Delete",
"change-language": "Change language",
"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. The entire system is set to Berlin timezone.",
"optional-note": "Optional note",
"note": "Note",
"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.",
@@ -81,5 +81,25 @@
"contactPage_messageLabel": "Message",
"contactPage_messagePlaceholder": "Enter your message here...",
"contactPage_messageErrorText": "This field cannot be empty.",
"contact": "Contact"
"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.",
"naas": "No-as-a-service",
"try-naas": "Click me",
"naas-error": "Error with no-as-a-service",
"naas-error-desc": "An error occurred while communicating with no-as-a-service.",
"naas-header": "A good way to say no...",
"error-deleting-loan-507": "The loan cannot be deleted because it has not been returned yet.",
"serviceDeactivatedHeadline": "Service deactivated",
"contactPage_serviceDeactivatedText": "The contact service is currently deactivated. Please try again later.",
"loan_page_serviceDeactivatedText": "The loan service is currently deactivated. Please try again later.",
"is-deactivated": "is deactivated.",
"deactivated-services": "Deactivated services",
"contactPage_messageErrorHeadline": "Error submitting message",
"contactPage_messageErrorText2": "Please enter a message before sending it."
}
+14 -7
View File
@@ -1,16 +1,23 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
import tailwindcss from "@tailwindcss/vite";
import tsconfigPaths from "vite-tsconfig-paths";
import path from "node:path";
export default defineConfig({
plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
plugins: [tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
server: {
host: "0.0.0.0",
port: 8001,
watch: {
usePolling: true,
allowedHosts: ["insta.the1s.de"],
port: 8101,
watch: { usePolling: true },
hmr: {
host: "insta.the1s.de",
port: 8101,
protocol: "wss",
},
},
});
+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
@@ -14,7 +14,7 @@ server {
}
location /backend/ {
proxy_pass http://borrow_system-backend_v2:8004/;
proxy_pass http://borrow_system-backend_v2:8102/;
}
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
+28 -40
View File
@@ -3675,12 +3675,16 @@
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/cosmiconfig": {
@@ -4466,9 +4470,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -4904,9 +4908,9 @@
}
},
"node_modules/minizlib": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz",
"integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
@@ -4915,21 +4919,6 @@
"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": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -5307,9 +5296,9 @@
}
},
"node_modules/react-router": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.2.tgz",
"integrity": "sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==",
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz",
"integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
@@ -5329,12 +5318,12 @@
}
},
"node_modules/react-router-dom": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.2.tgz",
"integrity": "sha512-Z4VM5mKDipal2jQ385H6UBhiiEDlnJPx6jyWsTYoZQdl5TrjxEV2a9yl3Fi60NBJxYzOTGTTHXPi0pdizvTwow==",
"version": "7.13.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz",
"integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==",
"license": "MIT",
"dependencies": {
"react-router": "7.8.2"
"react-router": "7.13.0"
},
"engines": {
"node": ">=20.0.0"
@@ -5492,9 +5481,9 @@
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/shebang-command": {
@@ -5649,16 +5638,15 @@
}
},
"node_modules/tar": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz",
"integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==",
"license": "ISC",
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
"integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.0.1",
"mkdirp": "^3.0.1",
"minizlib": "^3.1.0",
"yallist": "^5.0.0"
},
"engines": {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "admin",
"private": true,
"version": "0.0.0",
"version": "v1.3.2 (dev)",
"type": "module",
"scripts": {
"dev": "vite",
@@ -47,4 +47,4 @@
"vite": "^7.1.0",
"vite-tsconfig-paths": "^5.1.4"
}
}
}
+5 -2
View File
@@ -7,6 +7,7 @@ import UserTable from "../components/UserTable";
import ItemTable from "../components/ItemTable";
import LoanTable from "../components/LoanTable";
import APIKeyTable from "@/components/APIKeyTable";
import ServerConfig from "@/components/ServerConfig";
import { MoveLeft } from "lucide-react";
type DashboardProps = {
@@ -44,8 +45,9 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
viewSchliessfaecher={() => setActiveView("Schließfächer")}
viewUser={() => setActiveView("User")}
viewAPI={() => setActiveView("API")}
viewConfig={() => setActiveView("Server Konfiguration")}
/>
<Box flex="1" display="flex" flexDirection="column">
<Box flex="1" display="flex" flexDirection="column" minH={0}>
<Flex
as="header"
align="center"
@@ -66,7 +68,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
</Button>
</Flex>
</Flex>
<Box as="main" flex="1" p={6}>
<Box as="main" flex="1" p={6} minH={0} overflow="hidden">
{activeView === "" && (
<Flex
align="center"
@@ -88,6 +90,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
{activeView === "Ausleihen" && <LoanTable />}
{activeView === "Gegenstände" && <ItemTable />}
{activeView === "API" && <APIKeyTable />}
{activeView === "Server Konfiguration" && <ServerConfig />}
</Box>
</Box>
</Flex>
+2 -2
View File
@@ -3,6 +3,7 @@ import { useState } from "react";
import { loginFunc } from "@/utils/loginUser";
import MyAlert from "../components/myChakra/MyAlert";
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 [username, setUsername] = useState("");
@@ -43,8 +44,7 @@ const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
</Field.Root>
<Field.Root>
<Field.Label>password</Field.Label>
<Input
type="password"
<PasswordInput
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
+11
View File
@@ -9,6 +9,7 @@ type SidebarProps = {
viewSchliessfaecher: () => void;
viewUser: () => void;
viewAPI: () => void;
viewConfig: () => void;
};
const Sidebar: React.FC<SidebarProps> = ({
@@ -16,6 +17,7 @@ const Sidebar: React.FC<SidebarProps> = ({
viewGegenstaende,
viewUser,
viewAPI,
viewConfig
}) => {
const [info, setInfo] = useState<any>(null);
@@ -83,6 +85,15 @@ const Sidebar: React.FC<SidebarProps> = ({
>
API Keys
</Link>
<Link
px={3}
py={2}
rounded="md"
_hover={{ bg: "gray.700", textDecoration: "none" }}
onClick={viewConfig}
>
Server Konfiguration
</Link>
</VStack>
<Box mt="auto" pt={8} fontSize="xs" color="gray.500">
+228 -184
View File
@@ -57,32 +57,32 @@ const ItemTable: React.FC = () => {
const handleItemNameChange = (id: number, value: string) => {
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, item_name: value } : it))
prev.map((it) => (it.id === id ? { ...it, item_name: value } : it)),
);
};
const handleCanBorrowRoleChange = (id: number, value: string) => {
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, can_borrow_role: value } : it))
prev.map((it) => (it.id === id ? { ...it, can_borrow_role: value } : it)),
);
};
const handleLockerNumberChange = (id: number, value: string) => {
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, safe_nr: 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))
prev.map((it) => (it.id === id ? { ...it, door_key: value } : it)),
);
};
const setError = (
status: "error" | "success",
message: string,
description: string
description: string,
) => {
setIsError(false);
setErrorStatus(status);
@@ -102,7 +102,7 @@ const ItemTable: React.FC = () => {
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
}
},
);
const data = await response.json();
return data;
@@ -193,185 +193,229 @@ const ItemTable: React.FC = () => {
{/* make table fill available width, like UserTable */}
{!isLoading && (
<Table.Root
size="sm"
striped
w="100%"
style={{ tableLayout: "auto" }} // Spalten nach Content
>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Gegenstand</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausleih Berechtigung</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Im Schließfach</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Schließfachnummer</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Schlüssel</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Eintrag erstellt am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Eintrag aktualisiert am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>LaP *</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Dav **</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Aktionen</strong>
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{items.map((item) => (
<Table.Row key={item.id}>
<Table.Cell>{item.id}</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleItemNameChange(item.id, e.target.value)
}
value={item.item_name}
/>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleCanBorrowRoleChange(item.id, e.target.value)
}
value={item.can_borrow_role}
/>
</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
changeSafeState(item.id).then(() => setReload(!reload))
}
size="xs"
rounded="full"
px={3}
py={1}
gap={2}
variant="ghost"
color={item.in_safe ? "green.600" : "red.600"}
borderWidth="1px"
borderColor={item.in_safe ? "green.300" : "red.300"}
_hover={{
bg: item.in_safe ? "green.50" : "red.50",
borderColor: item.in_safe ? "green.400" : "red.400",
transform: "translateY(-1px)",
shadow: "sm",
}}
_active={{ transform: "translateY(0)" }}
aria-label={
item.in_safe ? "Mark as not in safe" : "Mark as in safe"
}
>
<Icon
as={item.in_safe ? CheckCircle2 : XCircle}
boxSize={3.5}
mr={2}
/>
<Text as="span" fontSize="xs" fontWeight="semibold">
{item.in_safe ? "Yes" : "No"}
</Text>
</Button>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleLockerNumberChange(item.id, e.target.value)
}
value={item.safe_nr}
/>
</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_updated_at)}</Table.Cell>
<Table.Cell>{item.last_borrowed_person}</Table.Cell>
<Table.Cell>{item.currently_borrowing}</Table.Cell>
<Table.Cell whiteSpace="nowrap">
<Button
onClick={() =>
handleEditItems(
item.id,
item.item_name,
item.safe_nr,
item.door_key,
item.can_borrow_role
).then((response) => {
if (response.success) {
setError(
"success",
"Gegenstand erfolgreich bearbeitet!",
"Gegenstand " +
'"' +
item.item_name +
'" mit ID ' +
item.id +
" bearbeitet."
);
}
})
}
colorPalette="teal"
size="sm"
>
<Save />
</Button>
<Button
onClick={() =>
deleteItem(item.id).then((response) => {
if (response.success) {
setItems(items.filter((i) => i.id !== item.id));
setError(
"success",
"Gegenstand gelöscht",
"Der Gegenstand wurde erfolgreich gelöscht."
);
}
})
}
colorPalette="red"
size="sm"
ml={2}
>
<Trash2 />
</Button>
</Table.Cell>
<Table.ScrollArea flex="1" minH={0} rounded="md" mt={4}>
<Table.Root
size="sm"
striped
stickyHeader
css={{
"& [data-sticky]": {
position: "sticky",
zIndex: 1,
bg: "bg",
_after: {
content: '""',
position: "absolute",
pointerEvents: "none",
top: "0",
bottom: "-1px",
width: "32px",
},
},
"& [data-sticky=end]": {
_after: {
insetInlineEnd: "0",
translate: "100% 0",
shadow: "inset 8px 0px 8px -8px rgba(0, 0, 0, 0.16)",
},
},
"& [data-sticky=start]": {
_after: {
insetInlineStart: "0",
translate: "-100% 0",
shadow: "inset -8px 0px 8px -8px rgba(0, 0, 0, 0.16)",
},
},
"& thead tr": {
shadow: "0 1px 0 0 {colors.border}",
"&:has(th[data-sticky])": {
zIndex: 2,
},
},
}}
>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Gegenstand</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausleih Berechtigung</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Im Schließfach</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Schließfachnummer</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Schlüssel</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Eintrag erstellt am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Eintrag aktualisiert am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>LaP *</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Dav **</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Aktionen</strong>
</Table.ColumnHeader>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Table.Header>
<Table.Body>
{items.map((item) => (
<Table.Row key={item.id}>
<Table.Cell>{item.id}</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleItemNameChange(item.id, e.target.value)
}
value={item.item_name}
/>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleCanBorrowRoleChange(item.id, e.target.value)
}
value={item.can_borrow_role}
/>
</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
changeSafeState(item.id).then(() => setReload(!reload))
}
size="xs"
rounded="full"
px={3}
py={1}
gap={2}
variant="ghost"
color={item.in_safe ? "green.600" : "red.600"}
borderWidth="1px"
borderColor={item.in_safe ? "green.300" : "red.300"}
_hover={{
bg: item.in_safe ? "green.50" : "red.50",
borderColor: item.in_safe ? "green.400" : "red.400",
transform: "translateY(-1px)",
shadow: "sm",
}}
_active={{ transform: "translateY(0)" }}
aria-label={
item.in_safe ? "Mark as not in safe" : "Mark as in safe"
}
>
<Icon
as={item.in_safe ? CheckCircle2 : XCircle}
boxSize={3.5}
mr={2}
/>
<Text as="span" fontSize="xs" fontWeight="semibold">
{item.in_safe ? "Yes" : "No"}
</Text>
</Button>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleLockerNumberChange(item.id, e.target.value)
}
value={item.safe_nr}
/>
</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_updated_at)}
</Table.Cell>
<Table.Cell>{item.last_borrowed_person}</Table.Cell>
<Table.Cell>{item.currently_borrowing}</Table.Cell>
<Table.Cell whiteSpace="nowrap">
<Button
onClick={() =>
handleEditItems(
item.id,
item.item_name,
item.safe_nr,
item.door_key,
item.can_borrow_role,
).then((response) => {
if (response.success) {
setError(
"success",
"Gegenstand erfolgreich bearbeitet!",
"Gegenstand " +
'"' +
item.item_name +
'" mit ID ' +
item.id +
" bearbeitet.",
);
}
})
}
colorPalette="teal"
size="sm"
>
<Save />
</Button>
<Button
onClick={() =>
deleteItem(item.id).then((response) => {
if (response.success) {
setItems(items.filter((i) => i.id !== item.id));
setError(
"success",
"Gegenstand gelöscht",
"Der Gegenstand wurde erfolgreich gelöscht.",
);
}
})
}
colorPalette="red"
size="sm"
ml={2}
>
<Trash2 />
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Table.ScrollArea>
)}
<Text>* LaP = Letzte ausleihende Person</Text>
<Text>** Dav = Derzeit ausgeliehen von</Text>
+127 -81
View File
@@ -1,5 +1,6 @@
import React from "react";
import {
Box,
Table,
Spinner,
Text,
@@ -31,7 +32,7 @@ const LoanTable: React.FC = () => {
const setError = (
status: "error" | "success",
message: string,
description: string
description: string,
) => {
setIsError(false);
setErrorStatus(status);
@@ -65,7 +66,7 @@ const LoanTable: React.FC = () => {
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
}
},
);
const data = await response.json();
return data;
@@ -83,7 +84,7 @@ const LoanTable: React.FC = () => {
}, [reload]);
return (
<>
<Box h="full" display="flex" flexDirection="column" minH={0}>
{/* Action toolbar */}
<HStack
mb={4}
@@ -131,86 +132,131 @@ const LoanTable: React.FC = () => {
</VStack>
)}
{!isLoading && (
<Table.Root size="sm" striped>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Besitzer</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausleih code</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Startdatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Enddatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausleihdatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Rückgabedatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Erstellt am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausgeliehene Artikel</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Notiz</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Aktionen</strong>
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{items.map((item) => (
<Table.Row color={item.deleted ? "red" : "white"} key={item.id}>
<Table.Cell>{item.id}</Table.Cell>
<Table.Cell>{item.username}</Table.Cell>
<Table.Cell>
<Code>{item.loan_code}</Code>
</Table.Cell>
<Table.Cell>{formatDateTime(item.start_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.end_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.take_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.returned_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.created_at)}</Table.Cell>
<Table.Cell>{item.loaned_items_name.join(", ")}</Table.Cell>
<Table.Cell>{item.note}</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
deleteLoan(item.id).then((response) => {
if (response.success) {
setItems(items.filter((i) => i.id !== item.id));
setError(
"success",
"Loan deleted",
"The loan has been successfully deleted."
);
}
})
}
colorPalette="red"
size="sm"
ml={2}
>
<Trash2 />
</Button>
</Table.Cell>
<Table.ScrollArea flex="1" minH={0} rounded="md" mt={4}>
<Table.Root
size="sm"
striped
stickyHeader
css={{
"& [data-sticky]": {
position: "sticky",
zIndex: 1,
bg: "bg",
_after: {
content: '""',
position: "absolute",
pointerEvents: "none",
top: "0",
bottom: "-1px",
width: "32px",
},
},
"& [data-sticky=end]": {
_after: {
insetInlineEnd: "0",
translate: "100% 0",
shadow: "inset 8px 0px 8px -8px rgba(0, 0, 0, 0.16)",
},
},
"& [data-sticky=start]": {
_after: {
insetInlineStart: "0",
translate: "-100% 0",
shadow: "inset -8px 0px 8px -8px rgba(0, 0, 0, 0.16)",
},
},
"& thead tr": {
shadow: "0 1px 0 0 {colors.border}",
"&:has(th[data-sticky])": {
zIndex: 2,
},
},
}}
>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Besitzer</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausleihcode</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Startdatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Enddatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausleihdatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Rückgabedatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Erstellt am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausgeliehene Artikel</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Notiz</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Aktionen</strong>
</Table.ColumnHeader>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Table.Header>
<Table.Body>
{items.map((item) => (
<Table.Row color={item.deleted ? "red" : "white"} key={item.id}>
<Table.Cell>{item.id}</Table.Cell>
<Table.Cell>{item.username}</Table.Cell>
<Table.Cell>
<Code>{item.loan_code}</Code>
</Table.Cell>
<Table.Cell>{formatDateTime(item.start_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.end_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.take_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.returned_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.created_at)}</Table.Cell>
<Table.Cell>{item.loaned_items_name.join(", ")}</Table.Cell>
<Table.Cell>{item.note}</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
deleteLoan(item.id).then((response) => {
if (response.success) {
setItems(items.filter((i) => i.id !== item.id));
setError(
"success",
"Loan deleted",
"The loan has been successfully deleted.",
);
}
})
}
colorPalette="red"
size="sm"
ml={2}
>
<Trash2 />
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Table.ScrollArea>
)}
</>
</Box>
);
};
+175
View File
@@ -0,0 +1,175 @@
import React from "react";
import {
Table,
Spinner,
Text,
VStack,
Heading,
Switch,
} from "@chakra-ui/react";
import MyAlert from "./myChakra/MyAlert";
import Cookies from "js-cookie";
import { useState, useEffect } from "react";
import { formatDateTime } from "@/utils/userFuncs";
import { API_BASE } from "@/config/api.config";
type Items = {
id: number;
function_name: string;
active: boolean;
entry_created_at: string;
updated_at: string | null;
};
const ServerConfig: React.FC = () => {
const [items, setItems] = useState<Items[]>([]);
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
const [errorMessage, setErrorMessage] = useState("");
const [errorDsc, setErrorDsc] = useState("");
const [isError, setIsError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [reload, setReload] = useState(false);
const handleSwitchChange = async (id: number, newState: boolean) => {
try {
const response = await fetch(
`${API_BASE}/api/admin/server-config/update?functionName=${encodeURIComponent(
items.find((item) => item.id === id)?.function_name || "",
)}&active=${newState}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
},
);
if (response.ok) {
setReload((prev) => !prev);
setError(
"success",
"Status updated",
"The function status was updated successfully.",
);
} else {
setError(
"error",
"Failed to update status",
"There is an error updating the function status.",
);
}
} catch (error) {
setError(
"error",
"Failed to update status",
"There is an error updating the function status.",
);
}
};
const setError = (
status: "error" | "success",
message: string,
description: string,
) => {
setIsError(false);
setErrorStatus(status);
setErrorMessage(message);
setErrorDsc(description);
setIsError(true);
};
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch(
`${API_BASE}/api/admin/server-config/all`,
{
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
},
);
const data = await response.json();
return data.data;
} catch (error) {
setError("error", "Failed to fetch items", "There is an error");
} finally {
setIsLoading(false);
}
};
fetchData().then((data) => {
if (Array.isArray(data)) {
setItems(data);
}
});
}, [reload]);
return (
<>
<Heading marginBottom={4} size="2xl">
Server Konfiguration
</Heading>
{isError && (
<MyAlert
status={errorStatus}
description={errorDsc}
title={errorMessage}
/>
)}
{isLoading && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">Loading...</Text>
</VStack>
)}
<Table.Root size="sm" striped w="100%" style={{ tableLayout: "auto" }}>
<Table.Header>
<Table.Row>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Service Name</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Toggle</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Eintrag erstellt am</strong>
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{items.map((item) => (
<Table.Row key={item.id}>
<Table.Cell whiteSpace="nowrap">{item.id}</Table.Cell>
<Table.Cell fontFamily="mono">{item.function_name}</Table.Cell>
<Table.Cell>
<Switch.Root
checked={item.active}
onCheckedChange={() =>
handleSwitchChange(item.id, !item.active)
}
>
<Switch.HiddenInput />
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
<Switch.Label />
</Switch.Root>
</Table.Cell>
<Table.Cell whiteSpace="nowrap">
{formatDateTime(item.entry_created_at)}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</>
);
};
export default ServerConfig;
+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 -7
View File
@@ -1,10 +1,11 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ESNext",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
@@ -23,14 +24,10 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Chakra / Pfad Aliases */
"baseUrl": ".",
/* Path aliases */
"paths": {
"@/*": ["./src/*"]
},
"forceConsistentCasingInFileNames": true,
"ignoreDeprecations": "5.0"
}
},
"include": ["src"]
}
+7 -3
View File
@@ -8,9 +8,13 @@ export default defineConfig({
plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
server: {
host: "0.0.0.0",
port: 8003,
watch: {
usePolling: true,
allowedHosts: ["admin.insta.the1s.de"],
port: 8103,
watch: { usePolling: true },
hmr: {
host: "admin.insta.the1s.de",
port: 8103,
protocol: "wss",
},
},
});
+3 -3
View File
@@ -1,11 +1,11 @@
{
"backend-info": {
"version": "v2.0.1 (dev)"
"version": "v2.2"
},
"frontend-info": {
"version": "v2.0 (dev)"
"version": "v2.2"
},
"admin-panel-info": {
"version": "v1.3 (dev)"
"version": "v1.4"
}
}
+74 -38
View File
@@ -1,21 +1,22 @@
{
"name": "backendv2",
"version": "1.0.0",
"version": "v2.1.1 (dev)",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backendv2",
"version": "1.0.0",
"version": "v2.1.1 (dev)",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
"express": "^5.1.0",
"express-rate-limit": "^8.4.1",
"jose": "^6.0.12",
"mysql2": "^3.14.3",
"nodemailer": "^7.0.6"
"nodemailer": "^8.0.6"
}
},
"node_modules/accepts": {
@@ -53,29 +54,49 @@
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.0",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.6.3",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"raw-body": "^3.0.0",
"type-is": "^2.0.0"
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -349,6 +370,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz",
"integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@@ -509,24 +548,21 @@
"node": ">= 0.8"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -656,9 +692,9 @@
}
},
"node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
@@ -731,9 +767,9 @@
}
},
"node_modules/nodemailer": {
"version": "7.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz",
"integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==",
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.6.tgz",
"integrity": "sha512-Nm2XeuDwwy2wi5A+8jPWwQwNzcjNjhWdE3pVLoXEusxJqCnAPAgnBGkSmiLknbnWuOF9qraRpYZjfxqtKZ4tPw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -791,9 +827,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -820,9 +856,9 @@
}
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "backendv2",
"version": "1.0.0",
"version": "v2.1.1 (dev)",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
@@ -15,8 +15,9 @@
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
"express": "^5.1.0",
"express-rate-limit": "^8.4.1",
"jose": "^6.0.12",
"mysql2": "^3.14.3",
"nodemailer": "^7.0.6"
"nodemailer": "^8.0.6"
}
}
}
@@ -17,7 +17,10 @@ export const getAllLoans = async () => {
};
export const deleteLoanById = async (loanId) => {
const [result] = await pool.query("DELETE FROM loans WHERE id = ?", [loanId]);
const [result] = await pool.query(
"UPDATE loans SET deleted = true, deleted_admin = true WHERE id = ?",
[loanId],
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
@@ -0,0 +1,26 @@
import mysql from "mysql2";
import dotenv from "dotenv";
dotenv.config();
const pool = mysql
.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
.promise();
export const getAllFunctions = async () => {
const [rows] = await pool.query("SELECT * FROM functions");
return { success: true, data: rows };
};
export const updateFunctionStatus = async (functionName, active) => {
const [result] = await pool.query(
"UPDATE functions SET active = ? WHERE function_name = ?",
[active, functionName],
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
@@ -0,0 +1,50 @@
import express from "express";
import { authenticateAdmin } from "../../services/authentication.js";
const router = express.Router();
import dotenv from "dotenv";
dotenv.config();
// database funcs import
import {
getAllFunctions,
updateFunctionStatus,
} from "./database/serverConfMgmt.database.js";
// Route to get all functions and their statuses
router.get("/all", async (req, res) => {
try {
const result = await getAllFunctions();
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to fetch functions" });
}
} catch (error) {
res
.status(500)
.json({ message: "An error occurred", error: error.message });
}
});
// Route to update the status of a function
router.post("/update", async (req, res) => {
const functionName = req.query.functionName;
let active = req.query.active;
if (active === "false") {
active = 0;
} else if (active === "true") {
active = 1;
} else {
res.status(406).json({ message: "Got unexpected format" });
}
const result = await updateFunctionStatus(functionName, active);
if (result.success) {
res.status(200).json({ message: "Function status updated successfully" });
} else {
res.status(500).json({ message: "Failed to update function status" });
}
});
export default router;
+46 -34
View File
@@ -22,7 +22,7 @@ export const getItemsFromDatabaseV2 = async () => {
export const getLoanByCodeV2 = async (loan_code) => {
const [result] = await pool.query(
"SELECT username, returned_date, take_date, lockers FROM loans WHERE loan_code = ?;",
[loan_code]
[loan_code],
);
if (result.length > 0) {
return { success: true, data: result[0] };
@@ -33,7 +33,7 @@ export const getLoanByCodeV2 = async (loan_code) => {
export const changeInSafeStateV2 = async (itemId) => {
const [result] = await pool.query(
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
[itemId]
[itemId],
);
if (result.affectedRows > 0) {
return { success: true };
@@ -42,50 +42,62 @@ export const changeInSafeStateV2 = async (itemId) => {
};
export const setReturnDateV2 = async (loanCode) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode]
);
try {
const [items] = await pool.query(
"SELECT loaned_items_id, username 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, message: "No items found for loan" };
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 itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ? AND returned_date IS NULL",
[loanCode],
);
const [setItemStates] = await pool.query(
"UPDATE items SET in_safe = 1, currently_borrowing = NULL, last_borrowed_person = (?) WHERE id IN (?)",
[owner[0].username, itemIds]
);
if (result.affectedRows === 0) return { success: false };
const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
[loanCode]
);
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],
);
}
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
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) => {
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(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode]
[loanCode],
);
const [owner] = await pool.query(
"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)
? items[0].loaned_items_id
@@ -93,18 +105,18 @@ export const setTakeDateV2 = async (loanCode) => {
const [setItemStates] = await pool.query(
"UPDATE items SET in_safe = 0, currently_borrowing = (?) WHERE id IN (?)",
[owner[0].username, itemIds]
[owner[0].username, itemIds],
);
const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
[loanCode]
"UPDATE loans SET take_date = NOW() WHERE loan_code = ? AND take_date IS NULL",
[loanCode],
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
return { message: "Failed to set take date", success: false };
};
export const getAllLoansV2 = async () => {
@@ -118,12 +130,12 @@ export const getAllLoansV2 = async () => {
export const openDoor = async (doorKey) => {
const [result] = await pool.query(
"SELECT safe_nr, id FROM items WHERE door_key = ?;",
[doorKey]
[doorKey],
);
if (result.length > 0) {
const [changeItemSate] = await pool.query(
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
[result[0].id]
[result[0].id],
);
if (changeItemSate.affectedRows > 0) {
return { success: true, data: result[0] };
+12 -6
View File
@@ -1,9 +1,12 @@
import express from "express";
import { authenticate } from "../../services/authentication.js";
import { checkIfServiceIsActive } from "../../services/functions.js";
const router = express.Router();
import dotenv from "dotenv";
dotenv.config();
const loan_service = "Loan Service";
import {
getItemsFromDatabaseV2,
changeInSafeStateV2,
@@ -39,6 +42,7 @@ router.post("/change-state/:key/:itemId", authenticate, async (req, res) => {
router.get(
"/get-loan-by-code/:key/:loan_code",
authenticate,
checkIfServiceIsActive(loan_service),
async (req, res) => {
const loan_code = req.params.loan_code;
const result = await getLoanByCodeV2(loan_code);
@@ -47,37 +51,39 @@ router.get(
} else {
res.status(404).json({ message: "Loan not found" });
}
}
},
);
// Route for API to set the return date by the loan code
router.post(
"/set-return-date/:key/:loan_code",
authenticate,
checkIfServiceIsActive(loan_service),
async (req, res) => {
const loanCode = req.params.loan_code;
const result = await setReturnDateV2(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
res.status(200).json({});
} else {
res.status(500).json({ message: "Failed to set return date" });
}
}
},
);
// Route for API to set the take away date by the loan code
router.post(
"/set-take-date/:key/:loan_code",
authenticate,
checkIfServiceIsActive(loan_service),
async (req, res) => {
const loanCode = req.params.loan_code;
const result = await setTakeDateV2(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
res.status(200).json({});
} 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
@@ -234,6 +234,23 @@ export const getBorrowableItemsFromDatabase = async (
};
export const SETdeleteLoanFromDatabase = async (loanId) => {
const [checkIfdatesReturned] = await pool.query(
"SELECT take_date, returned_date FROM loans WHERE id = ? AND deleted = 0",
[loanId],
);
if (checkIfdatesReturned.length === 0) {
return { success: false, code: "LOAN_NOT_FOUND" };
}
const { take_date, returned_date } = checkIfdatesReturned[0];
const bothNull = take_date === null && returned_date === null;
const bothSet = take_date !== null && returned_date !== null;
if (!(bothNull || bothSet)) {
return { success: false, code: "LOAN_NOT_RETURNED" };
}
const [result] = await pool.query(
"UPDATE loans SET deleted = 1 WHERE id = ?;",
[loanId],
@@ -14,7 +14,7 @@ const pool = mysql
export const loginFunc = async (username, password) => {
const [result] = await pool.query(
"SELECT * FROM users WHERE username = ? AND password = ?",
[username, password]
[username, password],
);
if (result.length > 0) return { success: true, data: result[0] };
return { success: false };
@@ -40,7 +40,7 @@ export const changePassword = async (username, oldPassword, newPassword) => {
// get user current password
const [user] = await pool.query(
"SELECT * FROM users WHERE username = ? AND password = ?",
[username, oldPassword]
[username, oldPassword],
);
if (user.length === 0) return { success: false };
@@ -48,8 +48,16 @@ export const changePassword = async (username, oldPassword, newPassword) => {
const [result] = await pool.query(
"UPDATE users SET password = ? WHERE username = ?",
[newPassword, username]
[newPassword, username],
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const getDeactivatedServices = async () => {
const [rows] = await pool.query("SELECT function_name FROM functions WHERE active = 0;");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
+192 -127
View File
@@ -1,8 +1,20 @@
import express from "express";
import { authenticate, generateToken } from "../../services/authentication.js";
const router = express.Router();
import {
checkIfServiceIsActive,
checkIfServiceIsActive2,
} from "../../services/functions.js";
// mailer imports
import { sendMail } from "../../services/mailer/send.js";
import { loanMail } from "../../services/mailer/templates/loan_created.js";
import dotenv from "dotenv";
dotenv.config();
const router = express.Router();
const loan_service = "Loan Service";
const loan_mailer_service = "Loan Mailer";
// database funcs import
import {
@@ -16,107 +28,135 @@ import {
setReturnDate,
setTakeDate,
} from "./database/loansMgmt.database.js";
import { sendMailLoan } from "./services/mailer.js";
router.post("/createLoan", authenticate, async (req, res) => {
try {
const { items, startDate, endDate, note } = req.body || {};
router.post(
"/createLoan",
checkIfServiceIsActive(loan_service),
authenticate,
async (req, res) => {
try {
const { items, startDate, endDate, note } = req.body || {};
if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ message: "Items array is required" });
}
if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ message: "Items array is required" });
}
// If dates are not provided, default to now .. +7 days
const start =
startDate ?? new Date().toISOString().slice(0, 19).replace("T", " ");
const end =
endDate ??
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 19)
.replace("T", " ");
// If dates are not provided, default to now .. +7 days
const start =
startDate ?? new Date().toISOString().slice(0, 19).replace("T", " ");
const end =
endDate ??
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 19)
.replace("T", " ");
// Coerce item IDs to numbers and filter invalids
const itemIds = items
.map((v) => Number(v))
.filter((n) => Number.isFinite(n));
// Coerce item IDs to numbers and filter invalids
const itemIds = items
.map((v) => Number(v))
.filter((n) => Number.isFinite(n));
if (itemIds.length === 0) {
return res.status(400).json({ message: "No valid item IDs provided" });
}
if (itemIds.length === 0) {
return res.status(400).json({ message: "No valid item IDs provided" });
}
const result = await createLoanInDatabase(
req.user.username,
start,
end,
note,
itemIds,
);
if (result.success) {
const mailInfo = await getLoanInfoWithID(result.data.id);
console.log(mailInfo);
sendMailLoan(
mailInfo.data.username,
mailInfo.data.loaned_items_name,
mailInfo.data.start_date,
mailInfo.data.end_date,
mailInfo.data.created_at,
const result = await createLoanInDatabase(
req.user.username,
start,
end,
note,
itemIds,
);
return res.status(201).json({
message: "Loan created successfully",
loanId: result.data.id,
loanCode: result.data.loan_code,
});
if (result.success) {
if (await checkIfServiceIsActive2(loan_mailer_service)) {
const mailInfo = await getLoanInfoWithID(result.data.id);
console.log(mailInfo);
const { html, text } = loanMail(
req.user.first_name + " " + req.user.last_name,
mailInfo.data.loaned_items_name,
mailInfo.data.start_date,
mailInfo.data.end_date,
mailInfo.data.created_at,
mailInfo.data.note,
);
await sendMail({
to: process.env.MAIL_SENDEES,
subject: "Neue Ausleihe erstellt!",
html,
text,
});
}
return res.status(201).json({
message: "Loan created successfully",
loanId: result.data.id,
loanCode: result.data.loan_code,
});
}
if (result.code === "CONFLICT") {
return res
.status(409)
.json({ message: "Items not available in the selected period" });
}
if (result.code === "BAD_REQUEST") {
return res.status(400).json({ message: result.message });
}
return res.status(500).json({ message: "Failed to create loan" });
} catch (err) {
console.error("createLoan error:", err);
return res.status(500).json({ message: "Failed to create loan" });
}
},
);
if (result.code === "CONFLICT") {
return res
.status(409)
.json({ message: "Items not available in the selected period" });
router.get(
"/loans",
checkIfServiceIsActive(loan_service),
authenticate,
async (req, res) => {
const result = await getLoansFromDatabase(req.user.username);
if (result.success) {
res.status(200).json(result.data);
} else if (result.status) {
res.status(200).json([]);
} else {
res.status(500).json({ message: "Failed to fetch loans" });
}
},
);
if (result.code === "BAD_REQUEST") {
return res.status(400).json({ message: result.message });
router.post(
"/set-return-date/:loan_code",
checkIfServiceIsActive(loan_service),
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" });
}
},
);
return res.status(500).json({ message: "Failed to create loan" });
} catch (err) {
console.error("createLoan error:", err);
return res.status(500).json({ message: "Failed to create loan" });
}
});
router.get("/loans", authenticate, async (req, res) => {
const result = await getLoansFromDatabase(req.user.username);
if (result.success) {
res.status(200).json(result.data);
} else if (result.status) {
res.status(200).json([]);
} else {
res.status(500).json({ message: "Failed to fetch loans" });
}
});
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.post(
"/set-take-date/:loan_code",
checkIfServiceIsActive(loan_service),
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) => {
const result = await getItems();
@@ -127,46 +167,71 @@ router.get("/all-items", authenticate, async (req, res) => {
}
});
router.delete("/delete-loan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await SETdeleteLoanFromDatabase(loanId);
if (result.success) {
res.status(200).json({ message: "Loan deleted successfully" });
} else {
res.status(500).json({ message: "Failed to delete loan" });
}
});
router.delete(
"/delete-loan/:id",
checkIfServiceIsActive(loan_service),
authenticate,
async (req, res) => {
const loanId = req.params.id;
const result = await SETdeleteLoanFromDatabase(loanId);
if (result.success) {
res.status(200).json({ message: "Loan deleted successfully" });
} else {
if (result.code === "LOAN_NOT_FOUND") {
res.status(404).json({ message: "Loan not found" });
}
router.get("/all-loans", authenticate, async (req, res) => {
const result = await getALLLoans();
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch loans" });
}
});
if (result.code === "LOAN_NOT_RETURNED") {
res.status(507).json({
message: "Cannot delete loan that has not been returned",
});
}
router.post("/borrowable-items", authenticate, async (req, res) => {
const { startDate, endDate } = req.body || {};
if (!startDate || !endDate) {
return res
.status(400)
.json({ message: "startDate and endDate are required" });
}
res.status(500).json({ message: "Failed to delete loan" });
}
},
);
const result = await getBorrowableItemsFromDatabase(
startDate,
endDate,
req.user.role,
);
if (result.success) {
// return the array directly for consistency with /items
return res.status(200).json(result.data);
} else {
return res
.status(500)
.json({ message: "Failed to fetch borrowable items" });
}
});
router.get(
"/all-loans",
checkIfServiceIsActive(loan_service),
authenticate,
async (req, res) => {
const result = await getALLLoans();
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch loans" });
}
},
);
router.post(
"/borrowable-items",
checkIfServiceIsActive(loan_service),
authenticate,
async (req, res) => {
const { startDate, endDate } = req.body || {};
if (!startDate || !endDate) {
return res
.status(400)
.json({ message: "startDate and endDate are required" });
}
const result = await getBorrowableItemsFromDatabase(
startDate,
endDate,
req.user.role,
);
if (result.success) {
// return the array directly for consistency with /items
return res.status(200).json(result.data);
} else {
return res
.status(500)
.json({ message: "Failed to fetch borrowable items" });
}
},
);
export default router;
-181
View File
@@ -1,181 +0,0 @@
import nodemailer from "nodemailer";
import dotenv from "dotenv";
dotenv.config();
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 }) {
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
const itemsList =
Array.isArray(items) && items.length
? `<ul style="margin:4px 0 0 18px; padding:0;">${items
.map(
(i) =>
`<li style="margin:2px 0; color:#111827; line-height:1.3;">${i}</li>`
)
.join("")}</ul>`
: "<span style='color:#111827;'>N/A</span>";
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root { color-scheme: light; supported-color-schemes: light; }
body { margin:0; padding:0; }
/* Mobile stacking */
@media (max-width:480px) {
.outer { width:100% !important; }
.pad-sm { padding:16px !important; }
.w-label { width:120px !important; }
}
/* Dark-mode override safety */
@media (prefers-color-scheme: dark) {
body, table, td, p, a, h1, h2, h3 { background:#ffffff !important; color:#111827 !important; }
.brand-header { background:${brand} !important; color:#ffffff !important; }
a { color:${brand} !important; }
}
</style>
</head>
<body bgcolor="#ffffff" style="background:#ffffff; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; color:#111827; -webkit-text-size-adjust:100%;">
<!-- Preheader (hidden) -->
<div style="display:none; max-height:0; overflow:hidden; opacity:0; mso-hide:all;">
Neue Ausleihe erstellt Übersicht der Buchung.
</div>
<div role="article" aria-roledescription="email" lang="de" style="padding:24px; background:#f2f4f7;">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" class="outer" style="max-width:600px; margin:0 auto; background:#ffffff; border:1px solid #e5e7eb; border-radius:14px; overflow:hidden;">
<tr>
<td class="brand-header" style="padding:22px 26px; background:${brand}; color:#ffffff;">
<h1 style="margin:0; font-size:18px; line-height:1.35; font-weight:600;">Neue Ausleihe erstellt</h1>
</td>
</tr>
<tr>
<td class="pad-sm" style="padding:24px 26px; color:#111827;">
<p style="margin:0 0 14px 0; line-height:1.4;">Es wurde eine neue Ausleihe angelegt. Hier sind die Details:</p>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse; font-size:14px; line-height:1.3; background:#fcfcfd; border:1px solid #e5e7eb; border-radius:10px; overflow:hidden;">
<tbody>
<tr>
<td class="w-label" style="padding:10px 14px; color:#6b7280; width:170px; border-bottom:1px solid #ececec;">Benutzer</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${
user || "N/A"
}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280; vertical-align:top; border-bottom:1px solid #ececec;">Ausgeliehene Gegenstände</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${itemsList}</td>
</tr>
<tr>
<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(
startDate
)}</td>
</tr>
<tr>
<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(
endDate
)}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280;">Erstellt am</td>
<td style="padding:10px 14px; font-weight:600; color:#111827;">${formatDateTime(
createdDate
)}</td>
</tr>
</tbody>
</table>
<p style="margin:22px 0 0 0; font-size:14px;">
<a href="https://admin.insta.the1s.de/api" style="display:inline-block; background:${brand}; color:#ffffff; text-decoration:none; padding:10px 16px; border-radius:6px; font-weight:600; font-size:14px;" target="_blank" rel="noopener noreferrer">
Übersicht öffnen
</a>
</p>
<p style="margin:18px 0 0 0; font-size:12px; color:#6b7280; line-height:1.4;">
Diese E-Mail wurde automatisch vom Ausleihsystem gesendet. Bitte nicht antworten.
</p>
</td>
</tr>
</table>
</div>
</body>
</html>`;
}
function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
const itemsText =
Array.isArray(items) && items.length ? items.join(", ") : "N/A";
return [
"Neue Ausleihe erstellt",
"",
`Benutzer: ${user || "N/A"}`,
`Gegenstände: ${itemsText}`,
`Start: ${formatDateTime(startDate)}`,
`Ende: ${formatDateTime(endDate)}`,
`Erstellt am: ${formatDateTime(createdDate)}`,
].join("\n");
}
export function sendMailLoan(user, items, startDate, endDate, createdDate) {
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 info = await transporter.sendMail({
from: '"Ausleihsystem" <noreply@mcs-medien.de>',
to: process.env.MAIL_SENDEES,
subject: "Eine neue Ausleihe wurde erstellt!",
text: buildLoanEmailText({
user,
items,
startDate,
endDate,
createdDate,
}),
html: buildLoanEmail({ user, items, startDate, endDate, createdDate }),
});
// debugging logs
// console.log("Message sent:", info.messageId);
})();
// console.log("sendMailLoan called");
}
@@ -1,45 +0,0 @@
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,
});
// debugging logs
// console.log("Message sent:", info.messageId);
})();
// console.log("sendMailLoan called");
}
+85 -34
View File
@@ -1,48 +1,99 @@
import express from "express";
import { authenticate, generateToken } from "../../services/authentication.js";
import { checkIfServiceIsActive } from "../../services/functions.js";
const router = express.Router();
import dotenv from "dotenv";
dotenv.config();
const user_frontend_service = "User Frontend";
const contact_form_service = "Contact Form Service";
// database funcs import
import { loginFunc, changePassword } from "./database/userMgmt.database.js";
import { sendMail } from "./services/mailer_v2.js";
import {
loginFunc,
changePassword,
getDeactivatedServices,
} from "./database/userMgmt.database.js";
router.post("/login", async (req, res) => {
const result = await loginFunc(req.body.username, req.body.password);
// mailer imports
import { sendMail } from "../../services/mailer/send.js";
import { contactMail } from "../../services/mailer/templates/contact.js";
router.post(
"/login",
checkIfServiceIsActive(user_frontend_service),
async (req, res) => {
const result = await loginFunc(req.body.username, req.body.password);
if (result.success) {
const token = await generateToken({
username: result.data.username,
is_admin: result.data.is_admin,
first_name: result.data.first_name,
last_name: result.data.last_name,
role: result.data.role,
});
res.status(200).json({ message: "Login successful", token });
} else {
res.status(401).json({ message: "Invalid credentials" });
}
},
);
router.post(
"/change-password",
checkIfServiceIsActive(user_frontend_service),
authenticate,
async (req, res) => {
const oldPassword = req.body.oldPassword;
const newPassword = req.body.newPassword;
const username = req.user.username;
const result = await changePassword(username, oldPassword, newPassword);
if (result.success) {
res.status(200).json({ message: "Password changed successfully" });
} else {
res.status(500).json({ message: "Failed to change password" });
}
},
);
router.post(
"/contact",
checkIfServiceIsActive(contact_form_service),
authenticate,
async (req, res) => {
try {
const message = req.body?.message;
const username = req.user?.first_name + " " + req.user?.last_name;
if (!username || !message) {
return res
.status(400)
.json({ message: "Username and message are required" });
}
const { html, text } = contactMail({ username, message });
await sendMail({
to: process.env.MAIL_SENDEES_CONTACT,
subject: "Neue Nachricht!",
html,
text,
});
res.status(200).json({ message: "Contact message sent successfully" });
} catch (error) {
console.error("Failed to send contact mail:", error);
res.status(500).json({ message: "Failed to send contact message" });
}
},
);
router.get("/deactivated-services", authenticate, async (req, res) => {
const result = await getDeactivatedServices();
if (result.success) {
const token = await generateToken({
username: result.data.username,
is_admin: result.data.is_admin,
first_name: result.data.first_name,
last_name: result.data.last_name,
role: result.data.role,
});
res.status(200).json({ message: "Login successful", token });
res.status(200).json(result.data);
} else {
res.status(401).json({ message: "Invalid credentials" });
res.status(500).json({ message: "Failed to fetch deactivated services" });
}
});
router.post("/change-password", authenticate, async (req, res) => {
const oldPassword = req.body.oldPassword;
const newPassword = req.body.newPassword;
const username = req.user.username;
const result = await changePassword(username, oldPassword, newPassword);
if (result.success) {
res.status(200).json({ message: "Password changed successfully" });
} else {
res.status(500).json({ message: "Failed to change password" });
}
});
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;
-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');
+13 -1
View File
@@ -27,6 +27,7 @@ CREATE TABLE loans (
loaned_items_id json NOT NULL DEFAULT ('[]'),
loaned_items_name json NOT NULL DEFAULT ('[]'),
deleted bool NOT NULL DEFAULT false,
deleted_admin bool NOT NULL DEFAULT false,
note varchar(500) DEFAULT NULL,
PRIMARY KEY (id),
CHECK (loan_code REGEXP '^[0-9]{6}$')
@@ -54,4 +55,15 @@ CREATE TABLE apiKeys (
entry_created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
CHECK (api_key REGEXP '^[0-9]{8}$')
) ENGINE=InnoDB;
) ENGINE=InnoDB;
CREATE TABLE functions (
id INT NOT NULL AUTO_INCREMENT,
function_name VARCHAR(500) NOT NULL UNIQUE,
active BOOLEAN NOT NULL DEFAULT true,
entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
entry_created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB;
INSERT INTO functions (function_name) VALUES ("Loan Mailer"), ("Loan Service"), ("Contact Form Service"), ("User Frontend"), ("API")
+34 -5
View File
@@ -1,8 +1,25 @@
import express from "express";
import cors from "cors";
import env from "dotenv";
import dotenv from "dotenv";
import info from "./info.json" assert { type: "json" };
import { authenticate } from "./services/authentication.js";
import { rateLimit } from "express-rate-limit";
dotenv.config();
const app = express();
const port = 8004;
const naasURL = process.env.NAAS_URL;
const limiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
limit: 50, // Limit each IP to 50 requests per `window` (here, per 1 minute).
standardHeaders: "draft-8", // draft-6: `RateLimit-*` headers; draft-7 & draft-8: combined `RateLimit` header
legacyHeaders: false, // Disable the `X-RateLimit-*` headers.
ipv6Subnet: 56, // Set to 60 or 64 to be less aggressive, or 52 or 48 to be more aggressive
// store: ... , // Redis, Memcached, etc. See below.
});
app.use(limiter);
// frontend routes
import loansMgmtRouter from "./routes/app/loanMgmt.route.js";
@@ -14,14 +31,11 @@ import loanDataMgmtRouter from "./routes/admin/loanDataMgmt.route.js";
import itemDataMgmtRouter from "./routes/admin/itemDataMgmt.route.js";
import apiDataMgmtRouter from "./routes/admin/apiDataMgmt.route.js";
import userMgmtRouterADMIN from "./routes/admin/userMgmt.route.js";
import serverConfMgmtRouter from "./routes/admin/serverConfMgmt.route.js";
// API routes
import apiRouter from "./routes/api/api.route.js";
env.config();
const app = express();
const port = 8004;
app.use(cors());
// Body-Parser VOR den Routen registrieren
app.use(express.json({ limit: "10mb" }));
@@ -37,6 +51,7 @@ app.use("/api/admin/user-data", userDataMgmtRouter);
app.use("/api/admin/item-data", itemDataMgmtRouter);
app.use("/api/admin/api-data", apiDataMgmtRouter);
app.use("/api/admin/user-mgmt", userMgmtRouterADMIN);
app.use("/api/admin/server-config", serverConfMgmtRouter);
// API routes
app.use("/api", apiRouter);
@@ -47,6 +62,20 @@ app.listen(port, () => {
console.log(`Server is running on port: ${port}`);
});
app.get("/no", async (req, res) => {
try {
const response = await fetch(naasURL);
if (!response.ok) {
res.status(500).send("Request to no-as-a-service went wrong.");
}
const data = await response.json();
res.json(data);
} catch (error) {
console.error("Error communicating with no-as-a-service:", error);
res.status(500).send("Error communicating with no-as-a-service.");
}
});
app.get("/verify", authenticate, async (req, res) => {
res.status(200).json({ message: "Token is valid", user: req.user });
});
+18
View File
@@ -1,8 +1,12 @@
import { SignJWT, jwtVerify } from "jose";
import env from "dotenv";
import { verifyAPIKeyDB } from "./database.js";
import { checkIfServiceIsActive2 } from "./functions.js";
env.config();
const api_service = "API";
const user_frontend_service = "User Frontend";
const secretKey = process.env.SECRET_KEY;
if (!secretKey) {
throw new Error("Missing SECRET_KEY environment variable");
@@ -45,6 +49,13 @@ export async function authenticate(req, res, next) {
const apiKey = req.params.key;
if (authHeader) {
const serviceActive = await checkIfServiceIsActive2(user_frontend_service);
if (!serviceActive) {
return res
.status(503)
.json({ message: "User Frontend is currently unavailable." });
}
const parts = authHeader.split(" ");
const scheme = parts[0];
const token = parts[1];
@@ -61,6 +72,13 @@ export async function authenticate(req, res, next) {
return res.status(403).json({ message: "Present token invalid" }); // present token invalid
}
} else if (apiKey) {
const serviceActive = await checkIfServiceIsActive2(api_service);
if (!serviceActive) {
return res
.status(503)
.json({ message: "API Service is currently unavailable." });
}
try {
await verifyAPIKey(apiKey);
return next();
+42
View File
@@ -0,0 +1,42 @@
import mysql from "mysql2";
import dotenv from "dotenv";
dotenv.config();
const pool = mysql
.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
.promise();
export function checkIfServiceIsActive(service) {
return async (req, res, next) => {
const [result] = await pool.query(
"SELECT * FROM functions WHERE function_name = ? AND active = 1;",
[service],
);
if (result.length > 0) {
return next();
}
return res
.status(503)
.json({ message: `-${service}- is currently unavailable.` });
};
}
export async function checkIfServiceIsActive2(service) {
const [result] = await pool.query(
"SELECT * FROM functions WHERE function_name = ? AND active = 1;",
[service],
);
if (result.length > 0) {
return true;
}
return false;
}
+13
View File
@@ -0,0 +1,13 @@
import { transporter } from "./transporter.js";
export async function sendMail({ to, subject, text, html }) {
const info = await transporter.sendMail({
from: '"Ausleihsystem" <noreply@mcs-medien.de>',
to,
subject,
text,
html,
});
console.log("Mail sent:", info.messageId);
return info;
}
@@ -0,0 +1,76 @@
export function contactMail({ username, message }) {
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
const html = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
<title>Neue Nachricht</title>
</head>
<body style="margin:0;padding:0;background:#f2f4f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;color:#111827;-webkit-text-size-adjust:100%;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">Neue Kontaktanfrage im Ausleihsystem.</div>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#f2f4f7;">
<tr>
<td align="center" style="padding:32px 16px;">
<table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width:600px;width:100%;border-radius:14px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.06);">
<!-- Header -->
<tr>
<td style="background:${brand};padding:32px 30px 28px;">
<h1 style="margin:0;font-size:24px;color:#ffffff;font-weight:700;">Neue Nachricht</h1>
<p style="margin:8px 0 0;font-size:14px;color:rgba(255,255,255,0.85);">Eine neue Kontaktanfrage ist eingegangen.</p>
</td>
</tr>
<!-- Accent line -->
<tr>
<td style="height:3px;line-height:3px;font-size:1px;background:${brand};">&nbsp;</td>
</tr>
<!-- Content -->
<tr>
<td style="background:#ffffff;padding:28px 30px;">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#fafbfc;border:1px solid #f3f4f6;border-radius:10px;border-collapse:collapse;">
<tr>
<td style="padding:14px 16px;color:#6b7280;font-size:13px;white-space:nowrap;vertical-align:top;border-bottom:1px solid #f3f4f6;">
Benutzername
</td>
<td style="padding:14px 16px;font-weight:600;color:#111827;font-size:14px;vertical-align:top;border-bottom:1px solid #f3f4f6;">
${username || "N/A"}
</td>
</tr>
<tr>
<td style="padding:14px 16px;color:#6b7280;font-size:13px;white-space:nowrap;vertical-align:top;">
Nachricht
</td>
<td style="padding:14px 16px;font-weight:600;color:#111827;font-size:14px;vertical-align:top;white-space:pre-line;">
${message || "N/A"}
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background:#f9fafb;padding:20px 30px;border-top:1px solid #e5e7eb;">
<p style="margin:0;font-size:12px;color:#9ca3af;text-align:center;line-height:1.6;">
Diese E-Mail wurde automatisch vom Ausleihsystem gesendet.<br>
Bitte antworten Sie nicht auf diese Nachricht.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
const text = `Neue Kontaktanfrage\n\nBenutzername: ${username}\nNachricht:\n${message}`;
return { html, text };
}
@@ -0,0 +1,124 @@
const formatDateTime = (value) => {
if (value == null) return "N/A";
const d = value instanceof Date ? value : new Date(value);
if (isNaN(d.getTime())) return "N/A";
return (
d.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}) + " Uhr"
);
};
export function loanMail(user, items, startDate, endDate, createdDate, note) {
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
const itemsHtml =
Array.isArray(items) && items.length
? items
.map(
(i) =>
`<span style="display:inline-block;background:#f0f9ff;color:#0369a1;padding:4px 12px;margin:2px 4px 2px 0;border-radius:20px;font-size:13px;font-weight:500;">${i}</span>`,
)
.join(" ")
: '<span style="color:#9ca3af;">Keine Gegenst&auml;nde</span>';
const row = (label, value, isLast = false) => `
<tr>
<td style="padding:14px 16px;color:#6b7280;font-size:13px;white-space:nowrap;vertical-align:top;${isLast ? "" : "border-bottom:1px solid #f3f4f6;"}">
${label}
</td>
<td style="padding:14px 16px;font-weight:600;color:#111827;font-size:14px;vertical-align:top;${isLast ? "" : "border-bottom:1px solid #f3f4f6;"}">
${value}
</td>
</tr>`;
const html = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
<title>Neue Ausleihe</title>
</head>
<body style="margin:0;padding:0;background:#f2f4f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;color:#111827;-webkit-text-size-adjust:100%;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">Neue Ausleihe erstellt Details zur Buchung.</div>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#f2f4f7;">
<tr>
<td align="center" style="padding:32px 16px;">
<table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width:600px;width:100%;border-radius:14px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.06);">
<!-- Header -->
<tr>
<td style="background:${brand};padding:32px 30px 28px;">
<h1 style="margin:0;font-size:24px;color:#ffffff;font-weight:700;">Neue Ausleihe</h1>
<p style="margin:8px 0 0;font-size:14px;color:rgba(255,255,255,0.85);">Es wurde soeben eine neue Ausleihe im System angelegt.</p>
</td>
</tr>
<!-- Accent line -->
<tr>
<td style="background:#ffffff;padding:0;height:3px;line-height:3px;font-size:1px;background:${brand};">&nbsp;</td>
</tr>
<!-- Details -->
<tr>
<td style="background:#ffffff;padding:28px 30px 10px;">
<p style="margin:0 0 14px;font-size:11px;font-weight:700;color:#9ca3af;letter-spacing:1.5px;text-transform:uppercase;">Details zur Ausleihe</p>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#fafbfc;border:1px solid #f3f4f6;border-radius:10px;border-collapse:collapse;">
${row("Benutzer", user || "N/A")}
${row("Gegenst&auml;nde", itemsHtml)}
${row("Startdatum", formatDateTime(startDate))}
${row("Enddatum", formatDateTime(endDate))}
${row("Erstellt am", formatDateTime(createdDate))}
${row("Notiz", note || '<span style="color:#9ca3af;">Keine Notiz</span>', true)}
</table>
</td>
</tr>
<!-- Button -->
<tr>
<td style="background:#ffffff;padding:20px 30px 32px;" align="center">
<a href="https://admin.insta.the1s.de/api" target="_blank" rel="noopener noreferrer"
style="display:inline-block;background:${brand};color:#ffffff;text-decoration:none;padding:13px 28px;border-radius:8px;font-weight:600;font-size:15px;">
Ausleihe ansehen &rarr;
</a>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background:#f9fafb;padding:20px 30px;border-top:1px solid #e5e7eb;">
<p style="margin:0;font-size:12px;color:#9ca3af;text-align:center;line-height:1.6;">
Diese E-Mail wurde automatisch vom Ausleihsystem gesendet.<br>
Bitte antworten Sie nicht auf diese Nachricht.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
const itemsText = Array.isArray(items) ? items.join(", ") : "N/A";
const text = [
"Neue Ausleihe erstellt",
"-".repeat(30),
`Benutzer: ${user || "N/A"}`,
`Gegenstaende: ${itemsText}`,
`Start: ${formatDateTime(startDate)}`,
`Ende: ${formatDateTime(endDate)}`,
`Erstellt: ${formatDateTime(createdDate)}`,
`Notiz: ${note || "Keine Notiz"}`,
"",
"-> https://admin.insta.the1s.de/api",
].join("\n");
return { html, text };
}
+13
View File
@@ -0,0 +1,13 @@
import nodemailer from "nodemailer";
import dotenv from "dotenv";
dotenv.config();
export 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,
},
});
+29
View File
@@ -0,0 +1,29 @@
# Changelog for upcoming version: vX.X
Introduction
## New features
-
## Improvements
-
## Fixed bugs
-
---
## New version numbers
**Backend:** vX.X
**Frontend:** vX.X
**Admin panel:** vX.X
---
-[Theis](https://portfolio-theis.de)
+29 -16
View File
@@ -1,23 +1,23 @@
services:
# usr-frontend_v2:
# container_name: borrow_system-usr-frontend
# build: ./FrontendV2
# ports:
# - "8001:80"
# restart: unless-stopped
usr-frontend_v2:
container_name: borrow_system-usr-frontend
networks:
- proxynet
build: ./FrontendV2
restart: unless-stopped
# admin-frontend:
# container_name: borrow_system-admin-frontend
# build: ./admin
# ports:
# - "8003:80"
# restart: unless-stopped
admin-frontend:
container_name: borrow_system-admin-frontend
networks:
- proxynet
build: ./admin
restart: unless-stopped
backend_v2:
container_name: borrow_system-backend_v2
networks:
- proxynet
build: ./backendV2
ports:
- "8004:8004"
environment:
NODE_ENV: production
DB_HOST: mysql_v2
@@ -30,6 +30,8 @@ services:
mysql_v2:
container_name: borrow_system-mysql-v2
networks:
- proxynet
image: mysql:8.0
restart: unless-stopped
environment:
@@ -39,9 +41,20 @@ services:
volumes:
- mysql-v2-data:/var/lib/mysql
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
ports:
- "3310:3306"
no-as-a-service:
container_name: borrow_system-naas
networks:
- proxynet
build:
context: ./no-as-a-service
dockerfile: Dockerfile
restart: always
volumes:
mysql-data:
mysql-v2-data:
networks:
proxynet:
external: true
+1
Submodule no-as-a-service added at 764062a307