149 Commits

Author SHA1 Message Date
233778a8ad added next js project/folder 2025-11-29 14:54:15 +01:00
31960d1ff8 fixed footer message 2025-11-25 18:02:05 +01:00
3bf5560834 edited docs 2025-11-25 17:09:22 +01:00
4c60fea4c4 fixed bug: mailer did not send email 2025-11-25 16:57:20 +01:00
0577a63205 deleted xls 2025-11-25 16:15:08 +01:00
fd2ccaa747 feat: add door_key field to items and update related logic in forms and database 2025-11-24 17:12:37 +01:00
df6b5eac59 fixed bug and edited version 2025-11-24 16:55:27 +01:00
d64489aed4 edited scheme but still not working 2025-11-24 16:05:56 +01:00
cb6b5858e5 refactor: rename lockerNumber to safe_nr and update related logic 2025-11-23 21:52:12 +01:00
85e6d7fe00 fixed bugs 2025-11-23 21:39:18 +01:00
4b9f55268c updated scheme 2025-11-23 21:25:08 +01:00
90ca266793 changed docker config 2025-11-23 20:26:49 +01:00
b9d67cd147 deleted old backend 2025-11-23 20:20:23 +01:00
58b5d29040 fixed rendering bug 2025-11-23 20:07:31 +01:00
baa74adcc1 updated scheme 2025-11-23 20:07:17 +01:00
07d194ee6a Refactor API and frontend components: update item state handling, adjust API key length, and improve table layout for MyLoansPage 2025-11-23 19:58:04 +01:00
0a4d981808 changed the design of the item table 2025-11-23 19:37:59 +01:00
a78118da8d refactored backend 2025-11-23 19:33:12 +01:00
8ce882a745 Enhance HomePage: bold start and end date labels for better visibility 2025-11-21 23:34:30 +01:00
c6571033b0 Fix routing and update translations: change landing page route to '/landingpage', update user info dialog, and enhance localization strings. 2025-11-21 23:31:41 +01:00
238cd9254a added user card. Not yet designed! 2025-11-21 17:22:32 +01:00
ca8030afbd Enhance user management: update User context and API to include first name, last name, and admin status 2025-11-21 17:10:48 +01:00
1076b12668 improved note making 2025-11-21 16:59:02 +01:00
80cb393768 Enhance item management: update API key display, add locker number input, and modify database schema for unique locker numbers 2025-11-21 16:55:18 +01:00
79486fe1cb updated translation 2025-11-21 13:43:32 +01:00
09ea1cb301 fixed bug: landingpage does not render content 2025-11-20 17:41:47 +01:00
db21bcf1b4 changed docs 2025-11-19 16:52:55 +01:00
4ec14416ca fixed some display bugs 2025-11-19 16:31:42 +01:00
6556d2c01d imroved translation 2025-11-18 10:20:10 +01:00
903e360c29 added new admin route for executing mysql commands 2025-11-18 10:18:25 +01:00
c5a9a09ef3 edited api docs 2025-11-17 22:55:23 +01:00
a191c9c053 refactored backend docs 2025-11-17 22:52:58 +01:00
084a0fa2e2 refactor: update API endpoints and enhance loan management features 2025-11-17 22:49:54 +01:00
88a2c74e88 adjusted gitignore 2025-11-17 21:38:26 +01:00
3a03457f5a adjusted new backend with new routes 2025-11-17 21:37:29 +01:00
757e13efe4 changed api and scheme 2025-11-17 21:20:57 +01:00
d2ee9d73c7 corrected routes 2025-11-13 22:02:14 +01:00
8c10e6e63f refactored docs 2025-11-13 20:48:06 +01:00
24bf5fcaaf Add file locations for authentication and API routes in documentation 2025-11-11 21:12:00 +01:00
6f03fd8032 Update API documentation to clarify API key requirements 2025-11-11 21:08:10 +01:00
17010d5480 edited docs 2025-11-11 21:06:13 +01:00
a8c5ef25f7 fixed 403 bug 2025-11-11 21:01:09 +01:00
eccd0135fc added api route. But still with bug: still getting 403 but have valid api key 2025-11-11 20:46:21 +01:00
8f294278d4 Add API routing and remove unused imports in user management 2025-11-11 17:32:26 +01:00
16e48aaf3f improved table view 2025-11-11 17:18:16 +01:00
e49700071b imrpoved table view 2025-11-11 17:11:20 +01:00
a8b4ac3d60 Refactor loan and user management components and backend routes
- Updated LoanTable component to fetch loan data from new API endpoint and display notes.
- Enhanced UserTable component to include additional user fields (first name, last name, email, admin status) and updated input handling.
- Modified fetcher utility to use new user data API endpoint.
- Adjusted login functionality to point to the new admin login endpoint and handle unauthorized access.
- Refactored user actions utility to align with updated API endpoints for user management.
- Updated backend routes for user and loan data management to reflect new structure and naming conventions.
- Revised SQL schema and mock data to accommodate new fields and constraints.
- Changed Docker configuration to use the new database name.
2025-11-11 17:08:45 +01:00
974a5a75d8 improved 2025-11-11 14:24:37 +01:00
b9783a1909 added api data admin route 2025-11-10 10:21:42 +01:00
304e73b459 Implement item and loan management routes with CRUD operations 2025-11-08 17:14:29 +01:00
12277abb9e completed userDataMgmt 2025-11-08 16:59:07 +01:00
20d22d6ce4 enhanced structure 2025-11-06 17:53:12 +01:00
27d21efefa began to refactor backend 2025-11-05 10:25:23 +01:00
3e67bf9052 edited docker config for backend 2025-11-03 21:12:58 +01:00
3438321765 refactored backendV2 2025-11-03 21:09:31 +01:00
29d47ddd9b refactor: update Dockerfiles and nginx configurations for consistency and optimization 2025-11-03 21:05:21 +01:00
7b298180e0 edited dockker config 2025-11-03 20:42:52 +01:00
9b3bd76c42 edited names 2025-11-02 21:22:25 +01:00
5b73b44e79 refactored apiKeys table structure and added new route files for loans and user management 2025-11-02 21:19:02 +01:00
cf4a003c51 removed writing error 2025-11-02 21:18:35 +01:00
592b60082b refactored api routes 2025-11-02 17:14:36 +01:00
a34292bda1 refactored authentication 2025-11-02 17:14:28 +01:00
2f37ae8067 added backendV2 2025-11-02 16:55:16 +01:00
f49da68e15 refactored scheme 2025-10-31 13:39:04 +01:00
9491da2950 edited scheme 2025-10-30 18:35:11 +01:00
a75ba12897 improved error handling 2025-10-30 17:32:55 +01:00
b52fe07618 refactor: update @tanstack/react-query to version 5.90.5 and restructure Footer component
feat: edited imports
2025-10-30 17:27:35 +01:00
0d3de4f705 added new data scheme 2025-10-30 13:55:16 +01:00
ef3f953ebd removed sth 2025-10-30 13:50:38 +01:00
1db4e69322 Remove unused components and files from the frontend, including Form4, Header, LoginForm, Object, Sidebar, and related utility functions. Clean up the project structure by deleting unnecessary CSS, TypeScript configuration files, and Vite configuration. This refactor aims to streamline the codebase and improve maintainability. 2025-10-27 20:40:53 +01:00
83f1c9d191 feat: add server-info endpoint and include server information in info.json 2025-10-26 21:55:59 +01:00
af513034ef chore: add logging for i18n variable to prevent unused variable tree shaking 2025-10-26 15:40:23 +01:00
3358c8f669 fixed render bug 2025-10-26 14:40:51 +01:00
d3f7a7570f feat: add Footer component and integrate it into App and LoginPage 2025-10-26 14:37:41 +01:00
c502601a2f feat: add language change functionality and update translations in Header and locale files 2025-10-26 14:22:19 +01:00
070a390da8 refactor: streamline language initialization and update Container component in HomePage and MyLoansPage 2025-10-26 14:05:54 +01:00
bcf93ee9eb added english language 2025-10-26 14:05:48 +01:00
9daff3ea5c refactor: update heading sizes in ItemTable, LoanTable, and UserTable components 2025-10-26 13:54:43 +01:00
71fea52da7 filter out deleted loans in getBorrowableItemsFromDatabase and createLoanInDatabase functions 2025-10-26 13:54:37 +01:00
a8821ceca8 refactored code 2025-10-26 13:39:09 +01:00
7e668e17d3 added german translation 2025-10-26 13:37:15 +01:00
965a4b97ee translated greeting 2025-10-26 12:53:07 +01:00
6054173b03 implemented i18n translation technology 2025-10-26 12:52:58 +01:00
5ba35bb471 remove unused mock data files and update docker-compose paths 2025-10-25 22:44:43 +02:00
47b5590394 removed landingpage from admin panel 2025-10-25 22:43:16 +02:00
47fec60b5b added landingpage and fixed routing 2025-10-25 22:41:43 +02:00
e9319b49ec enhanced Header component with mobile menu and password change dialog; updated HomePage layout 2025-10-25 22:31:54 +02:00
b98e38b38b enhanced MyLoansPage with confirmation dialog for loan deletion; improved table layout and added code formatting for loan codes 2025-10-25 22:11:44 +02:00
a24a3033d3 updated scheme 2025-10-25 21:53:08 +02:00
d94d68aa33 added password input component and integrated password change functionality in Header; updated LoginPage for localized labels 2025-10-25 21:49:28 +02:00
cc0dcaf664 added MyLoansPage component and integrated loan deletion functionality; updated routing in App and added Header component 2025-10-25 21:27:08 +02:00
7a79bf4436 added loan creation functionality and improved error handling in HomePage and Fetcher 2025-10-25 20:19:11 +02:00
4b00dd6554 added borrowable items fetching and date input functionality to HomePage 2025-10-25 20:01:06 +02:00
ba34a97328 added sf pro 2025-10-25 19:07:26 +02:00
a013ad0bb8 enhanced greeting 2025-10-25 16:47:32 +02:00
d7240584f9 added logout function and enhanced greeting 2025-10-25 16:44:30 +02:00
a0bdf5539c added greeting with context logic 2025-10-25 15:59:52 +02:00
770025f8fc added changelog mock (not yet functionaning) 2025-10-25 00:23:56 +02:00
960e91c38a feat: Implement authentication flow with token verification and protected routes 2025-10-24 20:45:37 +02:00
b99f52f09a feat: Initialize FrontendV2 project with React, Vite, and Tailwind CSS
- Add package.json with dependencies and scripts for development and build
- Include Vite logo and React logo SVGs in public/assets
- Set up Tailwind CSS in App.css and index.css
- Create main App component with routing for Home and Login pages
- Implement LoginPage with authentication logic and error handling
- Add HomePage component as a landing page
- Create MyAlert component for displaying alerts using Chakra UI
- Implement color mode toggle functionality with Chakra UI
- Set up global state management using Jotai for authentication
- Create ProtectedRoutes component to guard routes based on authentication
- Add utility components for Toaster and Tooltip using Chakra UI
- Configure Tailwind CSS and TypeScript settings for the project
- Implement AddLoan component for selecting loan periods and fetching available items
2025-10-24 20:21:32 +02:00
86af1a5edf implemented jotai atoms 2025-10-22 21:44:05 +02:00
49d4d13afc improved design of email 2025-10-05 15:55:05 +02:00
45fa095eaf fixed mail view issues 2025-10-05 15:49:38 +02:00
23be7e12c7 removed some logging stuff 2025-10-04 19:34:27 +02:00
6ea1ff799c added .env link function 2025-10-04 00:26:02 +02:00
5131266242 changed itemform placeholder for better understanding 2025-10-04 00:08:19 +02:00
bb17bc735c added landingpage to email 2025-10-04 00:07:32 +02:00
af7d15c97a add nodemailer integration for loan email notifications and implement loan info retrieval 2025-10-02 22:32:26 +02:00
04453fd885 improved design of the error message 2025-09-30 13:06:49 +02:00
bf36a6605f improved error handling for adding an item 2025-09-30 13:03:00 +02:00
8f9696991f improved error handling 2025-09-30 12:59:38 +02:00
9cad1e8b6b fixed minor display bugs 2025-09-30 12:53:58 +02:00
880029a0cf changed docs 2025-09-29 10:53:50 +02:00
32abe60d98 fixed api route for setting the return and take date 2025-09-29 10:44:51 +02:00
ea965971f1 Refactor Dashboard and Login components: add URL synchronization in Dashboard, update Login form submission handling 2025-09-28 16:07:39 +02:00
eff1f61422 Refactor API routes: remove API key requirement for allLoans and allItems endpoints, update comments for clarity 2025-09-27 23:29:19 +02:00
a4c0323100 changed docs 2025-09-27 23:29:08 +02:00
fc755edadf fixed scheme 2025-09-27 23:06:21 +02:00
0fca896cc2 Refactor API key validation: streamline error handling and enforce API key presence in routes 2025-09-27 22:54:15 +02:00
f83f321876 NOT WORKING - Implement API key management features: add API key creation and deletion, update API routes, and refactor related components. - NOT WORKING 2025-09-27 17:33:59 +02:00
b9d637665c changed data scheme 2025-09-27 16:40:36 +02:00
378720b235 created APIKeyTable 2025-09-27 16:37:57 +02:00
49f4ba8483 added itemview to landingpage 2025-09-22 13:22:27 +02:00
ea5d31384a added a bit documentation to the api file 2025-09-21 10:45:31 +02:00
7f5f464841 changed design for the landingpage 2025-09-21 10:45:16 +02:00
ab93c9959d fullfilled landingpage 2025-09-21 00:48:28 +02:00
679ef7dcbd feat: implement Landingpage component and update Layout to conditionally render it 2025-09-19 12:24:17 +02:00
c3572a3d70 fix: adjust icon placement and styling for safe state indicator 2025-09-16 13:49:44 +02:00
4fc60a08d9 refactor: update button for safe state with improved styling and text display 2025-09-16 13:48:56 +02:00
5159877d8d added changeSafeState function 2025-09-16 13:00:15 +02:00
b3ddfd9aa5 added working item change route 2025-09-16 11:13:19 +02:00
755ebfd06b added inpu elemts and backend API routes for changing the item table 2025-09-11 16:40:31 +02:00
e198fce791 fixed generated code 2025-09-08 19:21:31 +02:00
8341404f45 changed timezone 2025-09-05 11:27:13 +02:00
5291752403 fixed bug: onReturn & onTake functions are know functioaning 2025-09-03 15:24:04 +02:00
c0ae12185a redesgined header 2025-09-03 14:52:36 +02:00
68f13f369c add password change functionality with frontend integration 2025-09-03 14:50:35 +02:00
a24b2697b0 enhance loan handling by updating item states on take and return 2025-09-03 14:33:54 +02:00
13a654561e refactor user editing functionality to remove password handling 2025-09-03 14:25:12 +02:00
5a058de2f0 refactor error mgmt for creating new password 2025-09-03 14:16:25 +02:00
b8f13a37fd added function to change user password with the admin panel 2025-09-03 14:10:42 +02:00
423075e746 fixed bug in admin panel 2025-09-03 13:42:47 +02:00
99810d2b7d fixed bug/issue: #9 2025-09-03 13:40:55 +02:00
a1972f26d3 fixed bug/issue: #8 2025-09-03 13:16:51 +02:00
866f860d5a fixed bug/issue: #11 2025-09-03 13:14:30 +02:00
16e1dca43c fixed issue/bug: #10 2025-09-03 13:12:46 +02:00
c47c311ecd fixed issue/bug: #7 2025-09-03 13:11:03 +02:00
bd504f7817 fix: update favicon to user-star.svg and remove vite.svg; enhance Login component layout 2025-09-02 20:55:29 +02:00
b0914314bb deleted unnesecarry syntax 2025-09-02 20:51:02 +02:00
151 changed files with 15628 additions and 3871 deletions

1
.gitignore vendored
View File

@@ -109,7 +109,6 @@ backend/public/uploads/
*.sqlite3
# API keys and secrets (additional protection)
config/
secrets/
keys/

View File

@@ -1,22 +0,0 @@
# Changelog
v1.1
## Current hosted version
v1.1
> No changelog available.
## Upcoming changes
v1.2
### Fixes and improvements
- Implement user roles and permissions
- Improve form validation and error handling
- Add loading indicators for async actions
- Optimize performance for large datasets
### New features
- Admin panel for managing users, permissions and all of the system settings and database

View File

@@ -2,4 +2,6 @@
This document provides an overview of the backend API endpoints and their usage.
To get to that information, go to the `backend_API_docs` directory.
To get to that information, go to the `backend_API_docs` directory.
If you need help, see HELP.md file in this directory.

View File

@@ -1,56 +1,90 @@
# Backend API docs
# Borrow System API Documentation
If you want to cooperate with me, or build something new with my backend API, feel free to reach out!
On this page you will learn how my API works.
## General information
When you look at my backend folder and file structure, you can see that I have two files called `API`. The first file called `api.js` is for my web frontend, because this file works together with my JWT token service.
**\*But I have built a second API. You can see the second API file in the same directory, the file is called `apiV2.js`.**
This is the file that you can use to build an API.
But first you have to get the Admin API key, stored in an `.env` file on my server.
**Frontend:** https://insta.the1s.de
**Backend base URL:** `https://backend.insta.the1s.de/api`
---
## Authentication
All endpoints require the Admin API key (`ADMIN_ID`) as a URL parameter.
All API endpoints require **either**:
Example: `/apiV2/items/{ADMIN_ID}`
### 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/ABC123
```
Where `ABC123` is your API key.
The API key is validated server-side.
---
## URL
## Common Response Codes
- The frontend is currently running on `https://insta.the1s.de`.
- The backend is currently running on `https://backend.insta.the1s.de`.
You can see the status of this and all my other services at `https://status.the1s.de`.
- `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.
---
## Current endpoints
## Endpoints
### 1. Get All Items
**GET** `/apiV2/items/:key`
**GET** `/api/items/:key`
Returns a list of all items and their details.
Returns a list of all items.
#### Example Request
#### Path Parameters
```
GET https://backend.insta.the1s.de/apiV2/items/your_admin_key
- `:key` API key (string)
#### Authentication
- Either:
- Valid `Authorization: Bearer <token>`
- Or valid `:key` path parameter
#### Request Example
```http
GET /api/items/ABC123 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Example Response
or
```http
GET /api/items/dummyKey HTTP/1.1
Host: backend.insta.the1s.de
Authorization: Bearer <JWT_TOKEN>
```
#### Successful Response (200)
```json
{
"data": [
{
@@ -58,236 +92,109 @@ GET https://backend.insta.the1s.de/apiV2/items/your_admin_key
"item_name": "DJI 1er Mikro",
"can_borrow_role": 4,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 2,
"item_name": "DJI 2er Mikro 1",
"can_borrow_role": 4,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 3,
"item_name": "DJI 2er Mikro 2",
"can_borrow_role": 4,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 4,
"item_name": "Rode Richt Mikrofon",
"can_borrow_role": 2,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 5,
"item_name": "Kamera Stativ",
"can_borrow_role": 1,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 6,
"item_name": "SONY Kamera - inkl. Akkus und Objektiv",
"can_borrow_role": 1,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 7,
"item_name": "MacBook inkl. Adapter",
"can_borrow_role": 2,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 8,
"item_name": "SD Karten",
"can_borrow_role": 3,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 9,
"item_name": "Kameragimbal",
"can_borrow_role": 1,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 10,
"item_name": "ATEM MINI PRO",
"can_borrow_role": 1,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 11,
"item_name": "Handygimbal",
"can_borrow_role": 4,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 12,
"item_name": "Kameralfter",
"can_borrow_role": 1,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 13,
"item_name": "Kleine Kamera 1 - inkl. Objektiv",
"can_borrow_role": 2,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
},
{
"id": 14,
"item_name": "Kleine Kamera 2 - inkl. Objektiv",
"can_borrow_role": 2,
"inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z"
"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",
"currently_borrowing": null
}
]
}
```
Each item has the following properties:
#### Error Response (500)
- `id`: The unique identifier for the item.
- `item_name`: The name of the item.
- `can_borrow_role`: The role ID that is allowed to borrow the item.
- `inSafe`: Indicates whether the item is currently in the locker (1) or not (0). This variable/state can change over time.
_You also get an http 200 status code._
```json
{
"message": "Failed to fetch items"
}
```
---
### 2. Change Item Safe State
### 2. Toggle Item Safe State
**POST** `/apiV2/controlInSafe/:key/:itemId/:state`
Toggles `in_safe` between `0` and `1` for a given item.
Updates the `inSafe` state of an item (whether it is in the locker).
**Keep in mind that when you return a loan by code, the item states are automatically updated.**
- `state` must be `"1"` (in safe) or `"0"` (not in safe).
**POST** `/api/change-state/:key/:itemId`
#### Example Request
#### Path Parameters
```
POST https://backend.insta.the1s.de/apiV2/controlInSafe/your_admin_key/item_id/new_item_state
- `:key` API key (string)
- `:itemId` Item ID (integer)
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
POST /api/change-state/ABC123/42 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Example Response
#### Successful Response (200)
```
{}
```json
{
"data": {}
}
```
_An empty object means, that the operation was successful and no further information is returned._
_(Implementation currently only returns `{ success: true }`, so `data` may be empty.)_
_You also get an http 200 status code._
#### Error Response (500)
```json
{
"message": "Failed to update item state"
}
```
---
### 3. Set Return Date
### 3. Get Loan by Code
**POST** `/apiV2/setReturnDate/:key/:loan_code`
Fetch loan information by `loan_code`.
Sets the `returned_date` of a loan to the current server time.
**GET** `/api/get-loan-by-code/:key/:loan_code`
- `loan_code`: The unique code of the loan.
#### Path Parameters
#### Example Request
- `:key` API key (string)
- `:loan_code` Loan code (string)
```
POST https://backend.insta.the1s.de/apiV2/setReturnDate/your_admin_key/your_loan_code
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
GET /api/get-loan-by-code/ABC123/12345 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Example Response
#### Successful Response (200)
```
{}
```
_An empty object means, that the operation was successful and no further information is returned._
_You also get an http 200 status code._
---
### 4. Set Take Date
**POST** `/apiV2/setTakeDate/:key/:loan_code`
Sets the `take_date` of a loan to the current server time.
- `loan_code`: The unique code of the loan.
#### Example Request
```
POST https://backend.insta.the1s.de/apiV2/setTakeDate/your_admin_key/your_loan_code
```
#### Example Response
```
{}
```
_An empty object means, that the operation was successful and no further information is returned._
_You also get an http 2xx status code._
---
### 5. Get whole loan by loan code
**POST** `/getLoanByCode/:key/:loan_code`
Retrieves the details of a specific loan by its unique code.
- `loan_code`: The unique code of the loan.
#### Example Request
```
GET https://backend.insta.the1s.de/getLoanByCode/your_admin_key/your_loan_code
```
#### Example Response
```
```json
{
"data": {
"id": 6,
"username": "theis",
"loan_code": 646473,
"start_date": "2025-08-25T13:23:00.000Z",
"end_date": "2025-08-26T13:23:00.000Z",
"take_date": null,
"username": "john",
"returned_date": null,
"created_at": "2025-08-20T11:23:40.000Z",
"loaned_items_id": [
8,
9
],
"loaned_items_name": [
"SD Karten",
"Kameragimbal"
]
"take_date": "2025-01-01T10:00:00.000Z",
"lockers": "[1, 2, 3]"
}
}
```
_You also get an http 200 status code._
#### Error Response (404)
If the loan id does not exist, you will receive a 404 status code and an error message.
```
```json
{
"message": "Loan not found"
}
@@ -295,12 +202,172 @@ If the loan id does not exist, you will receive a 404 status code and an error m
---
## Error Handling
### 4. Set Loan Return Date
- `403 Forbidden`: Invalid or missing API key.
- `400 Bad Request`: Invalid parameters (e.g., wrong state value).
- `500 Internal Server Error`: Database or server error.
Sets `returned_date = NOW()` on a loan and updates related items:
- `in_safe = 1`
- `currently_borrowing = NULL`
- `last_borrowed_person = username`
**POST** `/api/set-return-date/:key/:loan_code`
#### Path Parameters
- `:key` API key (string)
- `:loan_code` Loan code (string)
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
POST /api/set-return-date/ABC123/12345 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
```json
{
"data": {}
}
```
#### Error Response (500)
```json
{
"message": "Failed to set return date"
}
```
---
If you have questions or want to collaborate, please reach out to me!
### 5. Set Loan Take Date
Sets `take_date = NOW()` on a loan and updates related items:
- `in_safe = 0`
- `currently_borrowing = username`
**POST** `/api/set-take-date/:key/:loan_code`
#### Path Parameters
- `:key` API key (string)
- `:loan_code` Loan code (string)
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
POST /api/set-take-date/ABC123/LOAN-12345 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
```json
{
"data": {}
}
```
#### Error Response (500)
```json
{
"message": "Failed to set take date"
}
```
---
### 6. Open Door by Door Key
Looks up an item by its `door_key`, toggles `in_safe`, and returns safe information.
**GET** `/api/open-door/:key/:doorKey`
#### Path Parameters
- `:key` API key (string)
- `:doorKey` Door key/token (string) used by hardware to identify the locker.
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
GET /api/open-door/ABC123/123 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
```json
{
"data": {
"safe_nr": 5,
"id": 42
}
}
```
#### Error Response (500)
```json
{
"message": "Failed to open door"
}
```
---
## 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.

19
FrontendV2/Dockerfile Normal file
View File

@@ -0,0 +1,19 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine AS runner
WORKDIR /usr/share/nginx/html
COPY --from=builder /app/dist .
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -4,15 +4,19 @@ This template provides a minimal setup to get React working in Vite with HMR and
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default tseslint.config([
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
@@ -20,11 +24,11 @@ export default tseslint.config([
// Other configs...
// Remove tseslint.configs.recommended and replace with this
...tseslint.configs.recommendedTypeChecked,
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
...tseslint.configs.strictTypeChecked,
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
...tseslint.configs.stylisticTypeChecked,
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
@@ -46,7 +50,7 @@ You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-re
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default tseslint.config([
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],

View File

@@ -3,9 +3,9 @@ import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { globalIgnores } from 'eslint/config'
import { defineConfig, globalIgnores } from 'eslint/config'
export default tseslint.config([
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],

View File

@@ -1,10 +1,10 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/shapes.svg" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ausleihsystem</title>
<title>frontendv2</title>
</head>
<body>
<div id="root"></div>

18
FrontendV2/nginx.conf Normal file
View File

@@ -0,0 +1,18 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
access_log off;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "frontend",
"name": "admin",
"private": true,
"version": "0.0.0",
"type": "module",
@@ -10,14 +10,21 @@
"preview": "vite preview"
},
"dependencies": {
"@chakra-ui/react": "^3.28.0",
"@emotion/react": "^11.14.0",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.85.5",
"@tanstack/react-query": "^5.90.5",
"i18next": "^25.6.0",
"jotai": "^2.15.0",
"js-cookie": "^3.0.5",
"lucide-react": "^0.539.0",
"next-themes": "^0.4.6",
"primeicons": "^7.0.0",
"primereact": "^10.9.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-i18next": "^16.2.0",
"react-icons": "^5.5.0",
"react-router-dom": "^7.8.0",
"react-toastify": "^11.0.5",
"split-lines": "^3.0.0",
@@ -39,6 +46,7 @@
"globals": "^16.3.0",
"typescript": "~5.8.3",
"typescript-eslint": "^8.39.0",
"vite": "^7.1.0"
"vite": "^7.1.0",
"vite-tsconfig-paths": "^5.1.4"
}
}

View File

Before

Width:  |  Height:  |  Size: 420 B

After

Width:  |  Height:  |  Size: 420 B

73
FrontendV2/src/App.css Normal file
View File

@@ -0,0 +1,73 @@
@import "tailwindcss";
:root {
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text",
"SF Pro Display", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
"Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", sans-serif;
}
html,
body,
#root {
font-family: var(--font-sans);
}
/* Display für größere Überschriften */
@font-face {
font-family: "SF Pro Display";
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Regular.woff2")
format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SF Pro Display";
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Medium.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SF Pro Display";
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* Text für Fließtext */
@font-face {
font-family: "SF Pro Text";
src: url("/src/assets/fonts/sf-pro/SFProText-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SF Pro Text";
src: url("/src/assets/fonts/sf-pro/SFProText-Medium.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SF Pro Text";
src: url("/src/assets/fonts/sf-pro/SFProText-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* Global anwenden mit Fallbacks */
:root {
--font-sans: "SF Pro Text", "SF Pro Display", -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
html,
body,
#root {
font-family: var(--font-sans);
}

96
FrontendV2/src/App.tsx Normal file
View File

@@ -0,0 +1,96 @@
import "./App.css";
import { LoginPage } from "@/pages/LoginPage";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { HomePage } from "@/pages/HomePage";
import { ProtectedRoutes } from "./utils/ProtectedRoutes";
import { useEffect, useState } from "react";
import Cookies from "js-cookie";
import { useAtom } from "jotai";
import { setIsLoggedInAtom } from "@/states/Atoms";
import { UserContext, type User } from "./states/Context";
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 { Footer } from "./components/footer/Footer";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { API_BASE } from "@/config/api.config";
const queryClient = new QueryClient();
function App() {
const [user, setUser] = useState<User | undefined>(undefined);
const [, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
const [, setTriggerLogout] = useAtom(triggerLogoutAtom);
useEffect(() => {
if (Cookies.get("token")) {
const verifyToken = async () => {
const response = await fetch(`${API_BASE}/verify`, {
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
if (response.ok) {
setTriggerLogout(false);
const data = await response.json();
setUser({
username: data.user.username,
is_admin: data.user.is_admin,
first_name: data.user.first_name,
last_name: data.user.last_name,
role: data.user.role,
});
setIsLoggedIn(true);
} else {
Cookies.remove("token");
setIsLoggedIn(false);
window.location.reload();
}
};
verifyToken();
}
// set initial language
if (!Cookies.get("language")) {
const getBrowserLanguage = () => {
const lang = navigator.languages?.[0] || navigator.language || "en";
return lang.split("-")[0].toLowerCase();
};
changeLanguage(getBrowserLanguage());
Cookies.set("language", getBrowserLanguage());
}
if (Cookies.get("language")) {
changeLanguage(Cookies.get("language") || "en");
}
}, []);
return (
<QueryClientProvider client={queryClient}>
<Flex direction="column" minH="100vh">
<Box as="main" flex="1">
<UserContext.Provider value={user}>
<BrowserRouter>
<Routes>
<Route element={<ProtectedRoutes />}>
<Route path="/" element={<HomePage />} />
<Route path="/my-loans" element={<MyLoansPage />} />
<Route path="/landingpage" element={<Landingpage />} />
</Route>
<Route path="/login" element={<LoginPage />} />
</Routes>
</BrowserRouter>
</UserContext.Provider>
</Box>
<Footer />
</Flex>
</QueryClientProvider>
);
}
export default App;

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@@ -0,0 +1,50 @@
{
"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)."
]
}
]
}
]
}

View File

@@ -0,0 +1,263 @@
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>
);
}

View File

@@ -0,0 +1,520 @@
import {
Button,
Flex,
Heading,
Stack,
Text,
CloseButton,
Dialog,
Portal,
HStack,
IconButton,
Menu,
Box,
Avatar,
Card,
Grid,
} from "@chakra-ui/react";
import { PasswordInput } from "@/components/ui/password-input";
import Cookies from "js-cookie";
import { useAtom } from "jotai";
import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms";
import { useNavigate } from "react-router-dom";
import {
CircleUserRound,
RotateCcwKey,
Code,
LifeBuoy,
LogOut,
CalendarPlus,
MoreVertical,
Languages,
Table,
} from "lucide-react";
import { useUserContext } from "@/states/Context";
import { useState } from "react";
import MyAlert from "./myChakra/MyAlert";
import { useTranslation } from "react-i18next";
import { API_BASE } from "@/config/api.config";
export const Header = () => {
const navigate = useNavigate();
const userData = useUserContext();
console.log(userData);
const { t } = useTranslation();
// Error handling states
const [isMsg, setIsMsg] = useState(false);
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
const [msgTitle, setMsgTitle] = useState("");
const [msgDescription, setMsgDescription] = useState("");
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [, setTriggerLogout] = useAtom(triggerLogoutAtom);
const [, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
// Dialog control
const [isPwOpen, setPwOpen] = useState(false);
const [userDialog, setUserDialog] = useState(false);
const changePassword = async () => {
if (newPassword !== confirmPassword) {
setMsgTitle(t("err_pw_change"));
setMsgDescription(t("pw_mismatch"));
setMsgStatus("error");
setIsMsg(true);
return;
}
const response = await fetch(`${API_BASE}/api/users/change-password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ oldPassword, newPassword }),
});
if (!response.ok) {
setMsgTitle(t("err_pw_change"));
setMsgDescription(t("pw_mismatch"));
setMsgStatus("error");
setIsMsg(true);
return;
}
setMsgTitle(t("pw_success"));
setMsgDescription(t("pw_success_desc"));
setMsgStatus("success");
setIsMsg(true);
setOldPassword("");
setNewPassword("");
setConfirmPassword("");
};
const username = userData.first_name ? userData.first_name : "N/A";
const fullname = userData.first_name + " " + userData.last_name;
const randomColor = [
"gray",
"red",
"orange",
"yellow",
"green",
"teal",
"blue",
"cyan",
"purple",
"pink",
];
const logout = () => {
Cookies.remove("token");
setIsLoggedIn(false);
setTriggerLogout(true);
navigate("/login", { replace: true });
};
return (
<Stack
as="header"
gap={3}
className="mb-6"
position="relative"
pr={{ base: 10, md: 0 }} // Platz für den Mobile-Button rechts
>
{/* Mobile: Drei-Punkte-Button, vertikal zentriert im Header */}
<Box
display={{ base: "block", md: "none" }}
position="absolute"
top="50%"
right="0"
transform="translateY(-50%)"
zIndex={2}
>
<Menu.Root>
<Menu.Trigger asChild>
<IconButton
aria-label="Aktionen"
variant="solid"
colorScheme="teal"
size="md"
borderRadius="full"
boxShadow="md"
>
<MoreVertical size={20} />
</IconButton>
</Menu.Trigger>
<Menu.Positioner>
<Menu.Content>
<Menu.Item
value="create-loan"
onSelect={() => navigate("/", { replace: true })}
children={
<HStack gap={3}>
<CalendarPlus size={16} />
<Text as="span">{t("create-loan")}</Text>
</HStack>
}
/>
<Menu.Item
value="my-loans"
onSelect={() => navigate("/my-loans", { replace: true })}
children={
<HStack gap={3}>
<CircleUserRound size={16} />
<Text as="span">{t("my-loans")}</Text>
</HStack>
}
/>
<Menu.Item
value="landingpage"
onSelect={() => navigate("/landingpage", { replace: true })}
children={
<HStack gap={3}>
<Table size={16} />
<Text as="span">{t("landingpage")}</Text>
</HStack>
}
/>
<Menu.Item
value="change-language"
onSelect={() => {
const currentLang = Cookies.get("language") || "en";
const newLang = currentLang === "en" ? "de" : "en";
Cookies.set("language", newLang);
window.location.reload();
}}
children={
<HStack gap={3}>
<Languages size={16} />
<Text as="span">{t("change-language")}</Text>
</HStack>
}
/>
<Menu.Item
value="help"
onSelect={() =>
window.open(
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki",
"_blank",
"noopener,noreferrer"
)
}
children={
<HStack gap={3}>
<LifeBuoy size={16} />
<Text as="span">{t("help")}</Text>
</HStack>
}
/>
<Menu.Item
value="source-code"
onSelect={() =>
window.open(
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system",
"_blank",
"noopener,noreferrer"
)
}
children={
<HStack gap={3}>
<Code size={16} />
<Text as="span">{t("source-code")}</Text>
</HStack>
}
/>
<Menu.Separator />
<Menu.Item
value="logout"
onSelect={logout}
children={
<HStack gap={3} color="red.500">
<LogOut size={16} />
<Text as="span">{t("logout")}</Text>
</HStack>
}
/>
</Menu.Content>
</Menu.Positioner>
</Menu.Root>
</Box>
<Flex
direction={{ base: "column", md: "row" }}
align={{ base: "stretch", md: "center" }}
justify="space-between"
gap={4}
>
{/* Left: Title + user info */}
<Stack gap={1}>
{/* Titelzeile ohne Mobile-Menu (wurde nach oben verlegt) */}
<Flex align="center" justify="space-between" gap={2}>
<Heading
size="2xl"
className="tracking-tight text-slate-900 dark:text-slate-100"
>
{t("app-title")}
</Heading>
</Flex>
<HStack gap={3} align="center" flexWrap="wrap">
<Text fontSize="md" className="text-slate-600 dark:text-slate-400">
{t("greeting")}
<strong>{username}</strong>!
</Text>
</HStack>
</Stack>
{/* Avatar: visible on mobile, hidden on desktop (desktop version is in the actions bar) */}
<HStack display={{ base: "flex", md: "none" }}>
<Avatar.Root>
<button
onClick={() => setUserDialog(true)}
style={{ cursor: "pointer" }}
>
<Avatar.Fallback name={fullname} />
</button>
</Avatar.Root>
</HStack>
{/* Right: Actions */}
{/* Desktop actions */}
<HStack
gap={2}
align="center"
justify="flex-end"
flexWrap="wrap"
display={{ base: "none", md: "flex" }}
>
{/* Desktop avatar, aligned with action buttons */}
<Avatar.Root
colorPalette={randomColor[Math.floor(Math.random() * 10)]}
>
<button
onClick={() => setUserDialog(true)}
style={{ cursor: "pointer" }}
>
<Avatar.Fallback name={fullname} />
</button>
</Avatar.Root>
<Button
colorScheme="teal"
onClick={() => navigate("/", { replace: true })}
>
<HStack gap={2}>
<CalendarPlus size={18} />
<Text as="span">{t("create-loan")}</Text>
</HStack>
</Button>
<Button onClick={() => navigate("/my-loans", { replace: true })}>
<HStack gap={2}>
<CircleUserRound size={18} />
<Text as="span">{t("my-loans")}</Text>
</HStack>
</Button>
<Button onClick={() => navigate("/landingpage", { replace: true })}>
<HStack gap={2}>
<Table size={18} />
<Text as="span">{t("landingpage")}</Text>
</HStack>
</Button>
<Button
variant="ghost"
onClick={() => {
const currentLang = Cookies.get("language") || "en";
const newLang = currentLang === "en" ? "de" : "en";
Cookies.set("language", newLang);
window.location.reload();
}}
>
<HStack gap={2}>
<Languages size={18} />
<Text as="span">{t("change-language")}</Text>
</HStack>
</Button>
<a
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki"
target="_blank"
>
<Button variant="ghost">
<HStack gap={2}>
<LifeBuoy size={18} />
<Text as="span">{t("help")}</Text>
</HStack>
</Button>
</a>
<a
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system"
target="_blank"
>
<Button variant="ghost">
<HStack gap={2}>
<Code size={18} />
<Text as="span">{t("source-code")}</Text>
</HStack>
</Button>
</a>
<Button onClick={logout} variant="outline" colorScheme="red">
<HStack gap={2}>
<LogOut size={18} />
<Text as="span">{t("logout")}</Text>
</HStack>
</Button>
</HStack>
</Flex>
{/* User Info Dialoge */}
{userDialog && (
<Flex
position="fixed"
inset={0}
zIndex={1000}
align="center"
justify="center"
bg="blackAlpha.400"
backdropFilter="blur(6px)"
>
<Card.Root maxW="sm" w="full" mx={4}>
<Card.Header>
<Card.Title>
<Flex justify="center" align="center" w="100%">
<Avatar.Root
size={"2xl"}
colorPalette={randomColor[Math.floor(Math.random() * 10)]}
>
<Avatar.Fallback name={fullname} />
</Avatar.Root>
</Flex>
</Card.Title>
<Card.Description>{t("user-info-desc")}</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Box as="dl">
<Grid
templateColumns="auto 1fr"
rowGap={2}
columnGap={4}
alignItems="start"
>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("first-name")}:
</Text>
<Text as="dd">{userData.first_name}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("last-name")}:
</Text>
<Text as="dd">{userData.last_name}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("username")}:
</Text>
<Text as="dd">{userData.username}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("role")}:
</Text>
<Text as="dd">{userData.role}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("admin-status")}:
</Text>
<Text as="dd">
{userData.is_admin ? t("yes") : t("no")}
</Text>
</Grid>
</Box>
<Button variant="solid" onClick={() => setPwOpen(true)}>
<HStack gap={2}>
<RotateCcwKey size={18} />
<Text as="span">{t("change-password")}</Text>
</HStack>
</Button>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
<Button variant="outline" onClick={() => setUserDialog(false)}>
{t("cancel")}
</Button>
</Card.Footer>
</Card.Root>
</Flex>
)}
{/* Passwort-Dialog (kontrolliert) */}
<Dialog.Root open={isPwOpen} onOpenChange={(e: any) => setPwOpen(e.open)}>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content maxW="md">
<Dialog.Header>
<Dialog.Title>{t("change-password")}</Dialog.Title>
</Dialog.Header>
<form
onSubmit={(e) => {
e.preventDefault();
changePassword();
}}
>
<Dialog.Body>
<Stack gap={3}>
<PasswordInput
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder={t("old-password")}
/>
<PasswordInput
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder={t("new-password")}
/>
<PasswordInput
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t("confirm-password")}
/>
</Stack>
</Dialog.Body>
<Dialog.Footer>
<Stack w="100%" gap={3}>
{isMsg && (
<MyAlert
status={msgStatus}
title={msgTitle}
description={msgDescription}
/>
)}
<HStack justify="flex-end" gap={2}>
<Dialog.ActionTrigger asChild>
<Button variant="outline">{t("cancel")}</Button>
</Dialog.ActionTrigger>
<Button type="submit" colorScheme="teal">
{t("save")}
</Button>
</HStack>
</Stack>
</Dialog.Footer>
</form>
<Dialog.CloseTrigger asChild>
<CloseButton size="sm" />
</Dialog.CloseTrigger>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
</Stack>
);
};

View File

@@ -0,0 +1,23 @@
import { Box } from "@chakra-ui/react";
import { useVersionInfoQuery } from "./versionInfo.query";
export const Footer = () => {
const { data: info } = useVersionInfoQuery();
return (
<Box
as="footer"
py={4}
textAlign="center"
position="fixed"
bottom="0"
left="0"
right="0"
>
Made with by Theis Gaedigk - Class of 2019 at MCS-Bochum
<br />
Frontend-Version: {info ? info["frontend-info"].version : "N/A"} |
Backend-Version: {info ? info["backend-info"].version : "N/A"}
</Box>
);
};

View File

@@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import { API_BASE } from "@/config/api.config";
export const useVersionInfoQuery = () =>
useQuery({
queryKey: ["versionInfo"],
queryFn: async () => {
const response = await fetch(`${API_BASE}/`, {
method: "GET",
});
if (response.ok) {
const data = await response.json();
return data;
} else {
console.error(
"Failed to fetch version info (versionInfo.query.ts): ",
response.statusText
);
return {
"backend-info": {
version: "N/A",
},
"frontend-info": {
version: "N/A",
},
};
}
},
});

View File

@@ -0,0 +1,22 @@
import React from "react";
import { Alert } from "@chakra-ui/react";
type MyAlertProps = {
status: "error" | "success";
title: string;
description: string;
};
const MyAlert: React.FC<MyAlertProps> = ({ title, description, status }) => {
return (
<Alert.Root status={status}>
<Alert.Indicator />
<Alert.Content>
<Alert.Title>{title}</Alert.Title>
<Alert.Description>{description}</Alert.Description>
</Alert.Content>
</Alert.Root>
);
};
export default MyAlert;

View File

@@ -0,0 +1,108 @@
"use client"
import type { IconButtonProps, SpanProps } from "@chakra-ui/react"
import { ClientOnly, IconButton, Skeleton, Span } from "@chakra-ui/react"
import { ThemeProvider, useTheme } from "next-themes"
import type { ThemeProviderProps } from "next-themes"
import * as React from "react"
import { LuMoon, LuSun } from "react-icons/lu"
export interface ColorModeProviderProps extends ThemeProviderProps {}
export function ColorModeProvider(props: ColorModeProviderProps) {
return (
<ThemeProvider attribute="class" disableTransitionOnChange {...props} />
)
}
export type ColorMode = "light" | "dark"
export interface UseColorModeReturn {
colorMode: ColorMode
setColorMode: (colorMode: ColorMode) => void
toggleColorMode: () => void
}
export function useColorMode(): UseColorModeReturn {
const { resolvedTheme, setTheme, forcedTheme } = useTheme()
const colorMode = forcedTheme || resolvedTheme
const toggleColorMode = () => {
setTheme(resolvedTheme === "dark" ? "light" : "dark")
}
return {
colorMode: colorMode as ColorMode,
setColorMode: setTheme,
toggleColorMode,
}
}
export function useColorModeValue<T>(light: T, dark: T) {
const { colorMode } = useColorMode()
return colorMode === "dark" ? dark : light
}
export function ColorModeIcon() {
const { colorMode } = useColorMode()
return colorMode === "dark" ? <LuMoon /> : <LuSun />
}
interface ColorModeButtonProps extends Omit<IconButtonProps, "aria-label"> {}
export const ColorModeButton = React.forwardRef<
HTMLButtonElement,
ColorModeButtonProps
>(function ColorModeButton(props, ref) {
const { toggleColorMode } = useColorMode()
return (
<ClientOnly fallback={<Skeleton boxSize="9" />}>
<IconButton
onClick={toggleColorMode}
variant="ghost"
aria-label="Toggle color mode"
size="sm"
ref={ref}
{...props}
css={{
_icon: {
width: "5",
height: "5",
},
}}
>
<ColorModeIcon />
</IconButton>
</ClientOnly>
)
})
export const LightMode = React.forwardRef<HTMLSpanElement, SpanProps>(
function LightMode(props, ref) {
return (
<Span
color="fg"
display="contents"
className="chakra-theme light"
colorPalette="gray"
colorScheme="light"
ref={ref}
{...props}
/>
)
},
)
export const DarkMode = React.forwardRef<HTMLSpanElement, SpanProps>(
function DarkMode(props, ref) {
return (
<Span
color="fg"
display="contents"
className="chakra-theme dark"
colorPalette="gray"
colorScheme="dark"
ref={ref}
{...props}
/>
)
},
)

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" }
}
}

View File

@@ -0,0 +1,15 @@
"use client"
import { ChakraProvider, defaultSystem } from "@chakra-ui/react"
import {
ColorModeProvider,
type ColorModeProviderProps,
} from "./color-mode"
export function Provider(props: ColorModeProviderProps) {
return (
<ChakraProvider value={defaultSystem}>
<ColorModeProvider {...props} />
</ChakraProvider>
)
}

View File

@@ -0,0 +1,43 @@
"use client"
import {
Toaster as ChakraToaster,
Portal,
Spinner,
Stack,
Toast,
createToaster,
} from "@chakra-ui/react"
export const toaster = createToaster({
placement: "bottom-end",
pauseOnPageIdle: true,
})
export const Toaster = () => {
return (
<Portal>
<ChakraToaster toaster={toaster} insetInline={{ mdDown: "4" }}>
{(toast) => (
<Toast.Root width={{ md: "sm" }}>
{toast.type === "loading" ? (
<Spinner size="sm" color="blue.solid" />
) : (
<Toast.Indicator />
)}
<Stack gap="1" flex="1" maxWidth="100%">
{toast.title && <Toast.Title>{toast.title}</Toast.Title>}
{toast.description && (
<Toast.Description>{toast.description}</Toast.Description>
)}
</Stack>
{toast.action && (
<Toast.ActionTrigger>{toast.action.label}</Toast.ActionTrigger>
)}
{toast.closable && <Toast.CloseTrigger />}
</Toast.Root>
)}
</ChakraToaster>
</Portal>
)
}

View File

@@ -0,0 +1,46 @@
import { Tooltip as ChakraTooltip, Portal } from "@chakra-ui/react"
import * as React from "react"
export interface TooltipProps extends ChakraTooltip.RootProps {
showArrow?: boolean
portalled?: boolean
portalRef?: React.RefObject<HTMLElement | null>
content: React.ReactNode
contentProps?: ChakraTooltip.ContentProps
disabled?: boolean
}
export const Tooltip = React.forwardRef<HTMLDivElement, TooltipProps>(
function Tooltip(props, ref) {
const {
showArrow,
children,
disabled,
portalled = true,
content,
contentProps,
portalRef,
...rest
} = props
if (disabled) return children
return (
<ChakraTooltip.Root {...rest}>
<ChakraTooltip.Trigger asChild>{children}</ChakraTooltip.Trigger>
<Portal disabled={!portalled} container={portalRef}>
<ChakraTooltip.Positioner>
<ChakraTooltip.Content ref={ref} {...contentProps}>
{showArrow && (
<ChakraTooltip.Arrow>
<ChakraTooltip.ArrowTip />
</ChakraTooltip.Arrow>
)}
{content}
</ChakraTooltip.Content>
</ChakraTooltip.Positioner>
</Portal>
</ChakraTooltip.Root>
)
},
)

View File

@@ -0,0 +1,4 @@
export const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";

70
FrontendV2/src/index.css Normal file
View File

@@ -0,0 +1,70 @@
@import "tailwindcss";
:root {
--font-sans: -apple-system, BlinkMacSystemFont, "SF Pro Text",
"SF Pro Display", "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell,
"Helvetica Neue", Arial, "Apple Color Emoji", "Segoe UI Emoji",
"Segoe UI Symbol", sans-serif;
}
html,
body,
#root {
font-family: var(--font-sans);
}
@font-face {
font-family: "SF Pro Display";
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Regular.woff2")
format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SF Pro Display";
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Medium.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SF Pro Display";
src: url("/src/assets/fonts/sf-pro/SFProDisplay-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SF Pro Text";
src: url("/src/assets/fonts/sf-pro/SFProText-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SF Pro Text";
src: url("/src/assets/fonts/sf-pro/SFProText-Medium.woff2") format("woff2");
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "SF Pro Text";
src: url("/src/assets/fonts/sf-pro/SFProText-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
:root {
--font-sans: "SF Pro Text", "SF Pro Display", -apple-system,
BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
html,
body,
#root {
font-family: var(--font-sans);
}

18
FrontendV2/src/main.tsx Normal file
View File

@@ -0,0 +1,18 @@
import { Provider } from "@/components/ui/provider";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import i18n from "./utils/i18n"; // import i18n configuration DO NOT REMOVE
// Prevent unused variable tree shaking
let i18nUnused = i18n;
console.log(i18nUnused);
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Provider>
<App />
</Provider>
</StrictMode>
);

View File

@@ -0,0 +1,201 @@
import {
Container,
Stack,
Text,
Button,
Input,
Spinner,
VStack,
Table,
InputGroup,
Span,
} from "@chakra-ui/react";
import { useAtom } from "jotai";
import { getBorrowableItems } from "@/utils/Fetcher";
import { useState } from "react";
import MyAlert from "@/components/myChakra/MyAlert";
import { borrowAbleItemsAtom } from "@/states/Atoms";
import { createLoan } from "@/utils/Fetcher";
import { Header } from "@/components/Header";
import { useTranslation } from "react-i18next";
export interface User {
username: string;
role: number;
}
export const HomePage = () => {
const { t } = useTranslation();
const [borrowableItems, setBorrowableItems] = useAtom(borrowAbleItemsAtom);
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [isLoadingA, setIsLoadingA] = useState(false);
const [selectedItems, setSelectedItems] = useState<number[]>([]);
const MAX_CHARACTERS = 500;
const [note, setNote] = useState("");
// Error handling states
const [isMsg, setIsMsg] = useState(false);
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
const [msgTitle, setMsgTitle] = useState("");
const [msgDescription, setMsgDescription] = useState("");
const handleCheckboxChange = (itemId: number) => {
setSelectedItems((prevSelected) =>
prevSelected.includes(itemId)
? prevSelected.filter((id) => id !== itemId)
: [...prevSelected, itemId]
);
};
return (
<Container className="px-6 sm:px-8 pt-10">
<Header />
{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(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") {
setMsgStatus("error");
setMsgTitle(response.title || t("error"));
setMsgDescription(response.description || t("unknown-error"));
setIsMsg(true);
return;
}
setBorrowableItems(response.data);
setIsMsg(false);
console.log(borrowableItems);
});
}}
>
{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"));
setIsMsg(true);
}
)
}
>
{t("create-loan")}
</Button>
)}
</Stack>
</Container>
);
};

View File

@@ -0,0 +1,267 @@
import React, { useEffect, useState } from "react";
import {
Spinner,
Text,
VStack,
Table,
Heading,
HStack,
Card,
SimpleGrid,
Button,
} 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";
export const formatDateTime = (value: string | null | undefined) => {
if (!value) return "N/A";
const m = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!m) return "N/A";
const [, y, M, d, h, min] = m;
return `${d}.${M}.${y} ${h}:${min} Uhr`;
};
type Loan = {
id: number;
username: string;
start_date: string;
end_date: string;
returned_date: string | null;
take_date: string | null;
loaned_items_name: string[] | string;
};
type Device = {
id: number;
item_name: string;
can_borrow_role: string;
in_safe: number;
entry_created_at: string;
last_borrowed_person: string | null;
currently_borrowing: string | null;
};
const Landingpage: React.FC = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [loans, setLoans] = useState<Loan[]>([]);
const [devices, setDevices] = useState<Device[]>([]);
const [isError, setIsError] = useState(false);
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
const [errorMessage, setErrorMessage] = useState("");
const [errorDsc, setErrorDsc] = useState("");
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 loanRes = await fetch(`${API_BASE}/api/loans/all-loans`, {
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
const loanData = await loanRes.json();
if (Array.isArray(loanData)) {
setLoans(loanData);
} else {
setError(
"error",
t("error-by-loading"),
t("unexpected-date-format_loan")
);
}
const deviceRes = await fetch(`${API_BASE}/api/loans/all-items`, {
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
const deviceData = await deviceRes.json();
if (Array.isArray(deviceData)) {
setDevices(deviceData);
} else {
setError(
"error",
t("error-by-loading"),
t("unexpected-date-format_device")
);
}
} catch (e) {
setError("error", t("error-by-loading"), t("error-fetching-loans"));
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
return (
<>
<Heading as="h1" size="lg" mb={2}>
Matthias-Claudius-Schule Technik
</Heading>
<Button onClick={() => navigate("/", { replace: true })}>
{t("back")}
</Button>
<Heading as="h2" size="md" mb={4}>
{t("all-loans")}
</Heading>
{isError && (
<MyAlert
status={errorStatus}
description={errorDsc}
title={errorMessage}
/>
)}
{isLoading && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">{t("loading")}</Text>
</VStack>
)}
{!isLoading && (
<Table.Root size="sm" striped>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>{t("username")}</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>{t("start-date")}</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>{t("end-date")}</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>{t("rented-items")}</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>{t("take-date")}</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>{t("return-date")}</strong>
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{loans.map((loan) => (
<Table.Row key={loan.id}>
<Table.Cell>{loan.id}</Table.Cell>
<Table.Cell>{loan.username}</Table.Cell>
<Table.Cell>{formatDateTime(loan.start_date)}</Table.Cell>
<Table.Cell>{formatDateTime(loan.end_date)}</Table.Cell>
<Table.Cell>
{Array.isArray(loan.loaned_items_name)
? loan.loaned_items_name.join(", ")
: loan.loaned_items_name}
</Table.Cell>
<Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell>
<Table.Cell>{formatDateTime(loan.returned_date)}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
)}
{!isLoading && loans.length === 0 && !isError && (
<Text color="gray.500" mt={2}>
{t("no-loans-found")}
</Text>
)}
<Heading as="h2" size="md" mb={4}>
{t("all-devices")}
</Heading>
{/* Responsive Grid mit gleich hohen Karten */}
<SimpleGrid minChildWidth="200px" gap={2} alignItems="stretch">
{devices.map((device) => (
<Card.Root
key={device.id}
size="sm"
bg={device.in_safe ? "green" : "red"}
h="full"
minH="100px"
>
<Card.Header>
<Heading size="md">
<strong>{device.item_name}</strong>
</Heading>
</Card.Header>
<Card.Body>
<Text>
<strong>{t("role")}</strong>: {device.can_borrow_role}
</Text>
<Text>
<strong>{t("last-borrowed-person")}</strong>:{" "}
{device.last_borrowed_person || "N/A"}
</Text>
<Text>
<strong>{t("currently-borrowed-by")}</strong>:{" "}
{device.currently_borrowing || "N/A"}
</Text>
</Card.Body>
</Card.Root>
))}
</SimpleGrid>
<HStack mt={3} gap={3} align="center" role="group" aria-label="Legende">
<Text fontWeight="medium" color="fg.muted">
{t("legend")}:
</Text>
<Button
size="sm"
variant="subtle"
colorPalette="green"
pointerEvents="none"
cursor="default"
borderRadius="full"
>
<HStack gap={2}>
<Text>{t("in-locker")}</Text>
</HStack>
</Button>
<Button
size="sm"
variant="subtle"
colorPalette="red"
pointerEvents="none"
cursor="default"
borderRadius="full"
>
<HStack gap={2}>
<Text>{t("not-in-locker")}</Text>
</HStack>
</Button>
</HStack>
</>
);
};
export default Landingpage;

View File

@@ -0,0 +1,119 @@
import { useState, useEffect } from "react";
import MyAlert from "../components/myChakra/MyAlert";
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 } 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";
export const LoginPage = () => {
const { t } = useTranslation();
const [isLoggedIn, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
const [triggerLogout, setTriggerLogout] = useAtom(triggerLogoutAtom);
const navigate = useNavigate();
useEffect(() => {
if (isLoggedIn) {
navigate("/", { replace: true });
window.location.reload(); // Wenn entfernt: Seite bleibt schwarz und muss manuell neu geladen werden
}
}, [isLoggedIn, navigate]);
const loginFnc = async (username: string, password: string) => {
const response = await fetch(`${API_BASE}/api/users/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (!response.ok) {
return {
success: false,
message: data.message ?? t("login-failed"),
description: data.description ?? "",
};
}
Cookies.set("token", data.token);
setIsLoggedIn(true);
return { success: true };
};
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [isError, setIsError] = useState(false);
const [errorMsg, setErrorMsg] = useState("");
const [errorDsc, setErrorDsc] = useState("");
const handleLogin = async () => {
const result = await loginFnc(username, password);
if (!result.success) {
setErrorMsg(result.message);
setErrorDsc(result.description);
setIsError(true);
return;
}
setTriggerLogout(false);
navigate("/", { replace: true });
};
if (isLoggedIn) {
return <Navigate to="/" 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)}
/>
</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} />
)}
<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>
);
};

View File

@@ -0,0 +1,258 @@
import { useEffect, useState } from "react";
import Cookies from "js-cookie";
import { useNavigate } from "react-router-dom";
import MyAlert from "@/components/myChakra/MyAlert";
import {
Container,
VStack,
Spinner,
Text,
Table,
Button,
CloseButton,
Dialog,
Portal,
Code,
Box,
} from "@chakra-ui/react";
import { Header } from "@/components/Header";
import { Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { API_BASE } from "@/config/api.config";
export const MyLoansPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
const [loans, setLoans] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [delLoanCode, setDelLoanCode] = useState<number | null>(null);
// Error handling states
const [isMsg, setIsMsg] = useState(false);
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
const [msgTitle, setMsgTitle] = useState("");
const [msgDescription, setMsgDescription] = useState("");
useEffect(() => {
if (!Cookies.get("token")) {
navigate("/login", { replace: true });
return;
}
const fetchLoans = async () => {
try {
setIsLoading(true);
const res = await fetch(`${API_BASE}/api/loans/loans`, {
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
if (!res.ok) {
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("error-fetching-loans"));
setIsMsg(true);
return;
}
const data = await res.json();
setLoans(data);
} catch (e) {
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("network-error-fetching-loans"));
setIsMsg(true);
} finally {
setIsLoading(false);
}
};
fetchLoans();
}, [navigate]);
const deleteLoan = async (loanId: number) => {
try {
const res = await fetch(`${API_BASE}/api/loans/delete-loan/${loanId}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
if (!res.ok) {
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("error-deleting-loan"));
setIsMsg(true);
return;
}
setLoans((prev) => prev.filter((loan) => loan.id !== loanId));
setMsgStatus("success");
setMsgTitle(t("success"));
setMsgDescription(t("loan-deletion-success"));
setIsMsg(true);
} catch (e) {
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("network-error-deleting-loan"));
setIsMsg(true);
}
};
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}`;
};
return (
<>
<Container className="px-6 sm:px-8 pt-10">
<Header />
{isMsg && (
<MyAlert
status={msgStatus}
title={msgTitle}
description={msgDescription}
/>
)}
{isLoading && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">{t("loading")}</Text>
</VStack>
)}
{loans && (
<Box
overflowX="auto"
width="100%"
// Optional: add bottom padding to avoid scrollbar overlap
pb={2}
>
<Table.Root
size="sm"
variant="outline"
// minWidth ensures we don't cram all columns on tiny screens;
// horizontal scrolling will appear instead.
style={{ tableLayout: "fixed", width: "100%", minWidth: "800px" }}
>
<Table.ColumnGroup>
{/* Ausleihcode */}
<Table.Column style={{ width: "14%" }} />
{/* Startdatum */}
<Table.Column style={{ width: "14%" }} />
{/* Enddatum */}
<Table.Column style={{ width: "14%" }} />
{/* Geräte (flexibler) */}
<Table.Column style={{ width: "28%" }} />
{/* Ausleihdatum */}
<Table.Column style={{ width: "14%" }} />
{/* Rückgabedatum */}
<Table.Column style={{ width: "14%" }} />
{/* Notiz */}
<Table.Column style={{ width: "14%" }} />
{/* Aktionen */}
<Table.Column style={{ width: "8%" }} />
</Table.ColumnGroup>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>{t("loan-code")}</Table.ColumnHeader>
<Table.ColumnHeader>{t("start-date")}</Table.ColumnHeader>
<Table.ColumnHeader>{t("end-date")}</Table.ColumnHeader>
<Table.ColumnHeader>{t("devices")}</Table.ColumnHeader>
<Table.ColumnHeader>{t("take-date")}</Table.ColumnHeader>
<Table.ColumnHeader>{t("return-date")}</Table.ColumnHeader>
<Table.ColumnHeader>{t("note")}</Table.ColumnHeader>
<Table.ColumnHeader>{t("actions")}</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{loans.map((loan) => (
<Table.Row key={loan.id}>
<Table.Cell>
<Text title={loan.loan_code}>
<Code variant="solid">{`${loan.loan_code}`}</Code>
</Text>
</Table.Cell>
<Table.Cell>{formatDate(loan.start_date)}</Table.Cell>
<Table.Cell>{formatDate(loan.end_date)}</Table.Cell>
<Table.Cell>
<Text>
{Array.isArray(loan.loaned_items_name)
? loan.loaned_items_name.join(", ")
: "-"}
</Text>
</Table.Cell>
<Table.Cell>{formatDate(loan.take_date)}</Table.Cell>
<Table.Cell>{formatDate(loan.returned_date)}</Table.Cell>
<Table.Cell>{loan.note}</Table.Cell>
<Table.Cell>
<Dialog.Root role="alertdialog">
<Dialog.Trigger asChild>
<Button
onClick={() => setDelLoanCode(loan.loan_code)}
aria-label="Ausleihe löschen"
style={{
display: "inline-flex",
alignItems: "center",
}}
>
<Trash2 />
</Button>
</Dialog.Trigger>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{t("sure")}</Dialog.Title>
</Dialog.Header>
<Dialog.Body>
<Text>
{t("sure-delete-loan-0")}
<strong>
<Code>{delLoanCode}</Code>
</strong>{" "}
{t("sure-delete-loan-1")}
<br />
{t("sure-delete-loan-2")}
</Text>
</Dialog.Body>
<Dialog.Footer>
<Dialog.ActionTrigger asChild>
<Button variant="outline">
{t("cancel")}
</Button>
</Dialog.ActionTrigger>
<Button
colorPalette="red"
onClick={() => deleteLoan(loan.id)}
>
<strong>{t("delete")}</strong>
</Button>
</Dialog.Footer>
<Dialog.CloseTrigger asChild>
<CloseButton size="sm" />
</Dialog.CloseTrigger>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Box>
)}
</Container>
</>
);
};

View File

@@ -0,0 +1,16 @@
import { atom } from "jotai";
interface Meta {
"backend-info": {
version: String;
};
"frontend-info": {
version: String;
};
}
export const testAtom = atom<number>(0);
export const setIsLoggedInAtom = atom<boolean>(false);
export const triggerLogoutAtom = atom<boolean>(false);
export const borrowAbleItemsAtom = atom<any[]>([]);
export const infoAtom = atom<Meta | undefined>(undefined);

View File

@@ -0,0 +1,22 @@
import { createContext } from "react";
import { useContext } from "react";
export interface User {
username: string;
is_admin: boolean;
first_name: string;
last_name: string;
role: number;
}
export const UserContext = createContext<User | undefined>(undefined);
export function useUserContext() {
const user = useContext(UserContext);
if (user === undefined) {
throw new Error("useUserContext must be used with a UserContext");
}
return user;
}

View File

@@ -0,0 +1,36 @@
# How to use Atoms
Atoms are the fundamental building blocks of state management in this system. They represent individual pieces of state that can be shared and manipulated across different components.
You can also name it global state.
## Creating an Atom
to create an atom you have to declare an atom like this:
```ts
import { atom } from 'jotai';
export const NAME_OF_YOUR_ATOM = atom<type_of_your_atom>(initial_value);
```
In this project we declare all atoms in the `states/Atoms.tsx`file. Which you can find above this README file.
## Using an Atom
To use an atom in your component, you can use the `useAtom` hook provided by Jotai. Here's an example of how to use an atom in a React component:
```tsx
import { useAtom } from 'jotai';
import { NAME_OF_YOUR_ATOM } from '@/states/Atoms';
const MyComponent = () => {
const [value, setValue] = useAtom(NAME_OF_YOUR_ATOM);
return (
<div>
<p>Current value: {value}</p>
<button onClick={() => setValue(newValue)}>Update Value</button>
</div>
);
};
```
As you can see, you can use `useAtom` like `useState` but the state is global. In this example `value` is the current state of the atom, and `setValue` is a function to update the state, which is also known as the setter function.

View File

@@ -0,0 +1,79 @@
import Cookies from "js-cookie";
import { API_BASE } from "@/config/api.config";
export const getBorrowableItems = async (
startDate: string,
endDate: string
) => {
try {
const response = await fetch(`${API_BASE}/api/loans/borrowable-items`, {
method: "POST",
headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`,
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({ startDate, endDate }),
});
if (!response.ok) {
return {
data: null,
status: "error",
title: "Server error",
description:
"Ein Fehler ist auf dem Server aufgetreten. Manchmal hilft es, die Seite neu zu laden.",
};
}
const data = await response.json();
return {
data: data,
status: "success",
title: null,
description: null,
};
} catch (error) {
return {
data: null,
status: "error",
title: "Netzwerkfehler",
description:
"Es konnte keine Verbindung zum Server hergestellt werden. Bitte überprüfe deine Internetverbindung.",
};
}
};
export const createLoan = async (
itemIds: number[],
startDate: string,
endDate: string,
note: string | null
) => {
const response = await fetch(`${API_BASE}/api/loans/createLoan`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token") || ""}`,
},
body: JSON.stringify({ items: itemIds, startDate, endDate, note }),
});
if (!response.ok) {
return {
data: null,
status: "error",
title: "Server error",
description:
"Ein Fehler ist auf dem Server aufgetreten. Manchmal hilft es, die Seite neu zu laden.",
};
}
const data = await response.json();
return {
data: data,
status: "success",
title: null,
description: null,
};
};

View File

@@ -0,0 +1,20 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
import Cookies from "js-cookie";
import { useContext } from "react";
import { UserContext } from "@/states/Context";
export const ProtectedRoutes = () => {
const user = useContext(UserContext);
const location = useLocation();
const hasToken = Boolean(Cookies.get("token"));
if (hasToken && !user) {
return null;
}
return user ? (
<Outlet />
) : (
<Navigate to="/login" replace state={{ from: location }} />
);
};

View File

@@ -0,0 +1,34 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import Cookies from "js-cookie";
import enLang from "./locales/en/en.json";
import deLang from "./locales/de/de.json";
// the translations
// (tip move them in a JSON file and import them,
// or even better, manage them separated from your code: https://react.i18next.com/guides/multiple-translation-files)
const resources = {
en: {
translation: enLang,
},
de: {
translation: deLang,
},
};
i18n
.use(initReactI18next) // passes i18n down to react-i18next
.init({
resources,
fallbackLng: "en", // use en if detected lng is not available
lng: Cookies.get("language") || "en", // language to use, more information here: https://www.i18next.com/overview/configuration-options#languages-namespaces-resources
// you can use the i18n.changeLanguage function to change the language manually: https://www.i18next.com/overview/api#changelanguage
// if you're using a language detector, do not define the lng option
interpolation: {
escapeValue: false, // react already safes from xss
},
});
export default i18n;

View File

@@ -0,0 +1,76 @@
{
"greeting": "Willkommen zurück, ",
"err_pw_change": "Passwortänderung fehlgeschlagen",
"pw_mismatch": "Bitte überprüfen Sie Ihre Eingaben",
"pw_success": "Passwort erfolgreich geändert",
"pw_success_desc": "Ihr Passwort wurde erfolgreich geändert.",
"create-loan": "Ausleihe erstellen",
"my-loans": "Meine Ausleihen",
"change-password": "Passwort ändern",
"help": "Hilfe",
"source-code": "Quellcode",
"logout": "Abmelden",
"old-password": "Altes Passwort",
"new-password": "Neues Passwort",
"confirm-password": "Neues Passwort wiederholen",
"cancel": "Abbrechen",
"save": "Speichern",
"start-date": "Startdatum",
"end-date": "Enddatum",
"missing-fields": "Fehlende Eingaben",
"missing-fields-desc": "Bitte Start- und Enddatum angeben.",
"error": "Fehler",
"unknown-error": "Unbekannter Frontend Fehler",
"get-borrowable-items": "Verfügbare Gegenstände abrufen",
"loading": "Laden...",
"item": "Gegenstand",
"success": "Erfolg",
"loan-success": "Ausleihe erfolgreich erstellt",
"error-by-loading": "Fehler beim Laden",
"unexpected-date-format_loan": "Unerwartetes Datumsformat erhalten. (Ausleihen)",
"unexpected-date-format_device": "Unerwartetes Datumsformat erhalten. (Gerät)",
"error-fetching-loans": "Die Ausleihen konnten nicht abgerufen werden.",
"all-loans": "Alle Ausleihen",
"username": "Benutzername",
"rented-items": "Ausgeliehene Gegenstände",
"return-date": "Rückgabedatum",
"take-date": "Abholdatum",
"no-loans-found": "Keine Ausleihen vorhanden.",
"all-devices": "Alle Geräte",
"rent-role": "Ausleihrolle",
"legend": "Legende",
"in-locker": "Im Schließfach",
"not-in-locker": "Nicht im Schließfach",
"login-failed": "Anmeldung fehlgeschlagen",
"login": "Anmelden",
"enter-credentials": "Bitte unten Ihre Anmeldedaten eingeben.",
"password": "Passwort",
"logout-success": "Erfolgreich abgemeldet",
"logout-success-desc": "Sie wurden erfolgreich abgemeldet.",
"network-error-fetching-loans": "Netzwerkfehler beim Laden der Ausleihen.",
"error-deleting-loan": "Die Ausleihe konnte nicht gelöscht werden.",
"loan-deletion-success": "Die Ausleihe wurde erfolgreich gelöscht.",
"network-error-deleting-loan": "Netzwerkfehler beim Löschen der Ausleihe.",
"loan-code": "Ausleihcode",
"devices": "Geräte",
"actions": "Aktionen",
"sure": "Sind Sie sicher?",
"sure-delete-loan-0": "Möchten Sie die Ausleihe mit dem ",
"sure-delete-loan-1": " Ausleihcode wirklich löschen?",
"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.",
"optional-note": "Optionale Notiz",
"note": "Notiz",
"user-info-desc": "Hier können Sie Ihre persönlichen Informationen einsehen und ändern.",
"role": "Rolle",
"admin-status": "Admin-Status",
"first-name": "Vorname",
"last-name": "Nachname",
"app-title": "Ausleihsystem",
"last-borrowed-person": "Zuletzt ausgeliehen von",
"currently-borrowed-by": "Derzeit ausgeliehen von",
"back": "Zurückgehen",
"landingpage": "Übersichtsseite"
}

View File

@@ -0,0 +1,76 @@
{
"greeting": "Welcome back, ",
"err_pw_change": "Password change failed",
"pw_mismatch": "Please check your input",
"pw_success": "Password changed successfully",
"pw_success_desc": "Your password was changed successfully.",
"create-loan": "Create loan",
"my-loans": "My loans",
"change-password": "Change password",
"help": "Help",
"source-code": "Source code",
"logout": "Log out",
"old-password": "Old password",
"new-password": "New password",
"confirm-password": "Repeat new password",
"cancel": "Cancel",
"save": "Save",
"start-date": "Start date",
"end-date": "End date",
"missing-fields": "Missing fields",
"missing-fields-desc": "Please provide start and end date.",
"error": "Error",
"unknown-error": "Unknown frontend error",
"get-borrowable-items": "Fetch available items",
"loading": "Loading...",
"item": "Item",
"success": "Success",
"loan-success": "Loan created successfully",
"error-by-loading": "Error while loading",
"unexpected-date-format_loan": "Unexpected date format received. (Loans)",
"unexpected-date-format_device": "Unexpected date format received. (Device)",
"error-fetching-loans": "The loans could not be retrieved.",
"all-loans": "All loans",
"username": "Username",
"rented-items": "Borrowed items",
"return-date": "Return date",
"take-date": "Collection date",
"no-loans-found": "No loans found.",
"all-devices": "All devices",
"rent-role": "Loan role",
"legend": "Legend",
"in-locker": "In locker",
"not-in-locker": "Not in locker",
"login-failed": "Login failed",
"login": "Log in",
"enter-credentials": "Please enter your credentials below.",
"password": "Password",
"logout-success": "Successfully logged out",
"logout-success-desc": "You have been logged out successfully.",
"network-error-fetching-loans": "Network error while loading loans.",
"error-deleting-loan": "The loan could not be deleted.",
"loan-deletion-success": "The loan was deleted successfully.",
"network-error-deleting-loan": "Network error while deleting the loan.",
"loan-code": "Loan code",
"devices": "Devices",
"actions": "Actions",
"sure": "Are you sure?",
"sure-delete-loan-0": "Do you really want to delete the loan with the ",
"sure-delete-loan-1": " loan code?",
"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.",
"optional-note": "Optional note",
"note": "Note",
"user-info-desc": "Here you can view and edit your personal information.",
"role": "Role",
"admin-status": "Admin status",
"first-name": "First name",
"last-name": "Last name",
"app-title": "Borrow System",
"last-borrowed-person": "Last borrowed by",
"currently-borrowed-by": "Currently borrowed by",
"back": "Go back",
"landingpage": "Overview page"
}

View File

@@ -5,6 +5,7 @@
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
@@ -21,7 +22,12 @@
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"noUncheckedSideEffectImports": true,
/* Path aliases */
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}

View File

@@ -4,6 +4,7 @@
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */

16
FrontendV2/vite.config.ts Normal file
View File

@@ -0,0 +1,16 @@
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";
export default defineConfig({
plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
server: {
host: "0.0.0.0",
port: 8001,
watch: {
usePolling: true,
},
},
});

View File

@@ -1,276 +0,0 @@
import React, { useState } from "react";
// Beispiel-Daten für die Übersicht in der Seitenleiste
const allItems = [
{ id: 1, name: "Kamera" },
{ id: 2, name: "Mikrofon" },
{ id: 3, name: "Licht-Set" },
{ id: 4, name: "Stativ" },
];
// Beispiel-Ausleihen, später per API dynamisch!
const loans = [
{
itemId: 1,
username: "max",
start: "2025-01-01T08:00",
end: "2025-01-01T18:00",
loanCode: "123456",
},
{
itemId: 3,
username: "sara",
start: "2025-01-02T10:00",
end: "2025-01-02T16:00",
loanCode: "654321",
},
];
// Dummy: Für das Beispiel sind einige Items "nicht verfügbar" bei bestimmten Zeiträumen
function getAvailableItems(start: string, end: string) {
if (start.startsWith("2025-01-01")) {
return allItems.filter(
(item) => item.name === "Kamera" || item.name === "Stativ"
);
}
return allItems;
}
export default function App() {
const [step, setStep] = useState<1 | 2 | 3>(1);
const [startDate, setStartDate] = useState("");
const [endDate, setEndDate] = useState("");
const [availableItems, setAvailableItems] = useState<typeof allItems>([]);
const [selectedItem, setSelectedItem] = useState<number | null>(null);
// Dummy Code für das Design
const loanCode = "123456";
return (
<div className="min-h-screen flex bg-gradient-to-r from-blue-50 via-white to-blue-100">
{/* Seitenleiste */}
<aside className="w-80 min-h-screen bg-white/90 backdrop-blur border-r border-blue-100 shadow-xl flex flex-col p-8">
<h2 className="text-2xl font-extrabold mb-6 text-blue-700 tracking-tight flex items-center gap-2">
<svg
className="w-7 h-7 text-blue-500"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M16.5 7.5V4.75A2.25 2.25 0 0 0 14.25 2.5h-4.5A2.25 2.25 0 0 0 7.5 4.75V7.5m9 0h-9m9 0v11.75A2.25 2.25 0 0 1 14.25 21.5h-4.5A2.25 2.25 0 0 1 7.5 19.25V7.5m9 0h-9"
/>
</svg>
Ausleih-Übersicht
</h2>
<ul className="space-y-5 flex-1">
{allItems.map((item) => {
const itemLoans = loans.filter((loan) => loan.itemId === item.id);
return (
<li
key={item.id}
className="bg-white/80 rounded-xl p-4 shadow hover:shadow-md transition"
>
<div className="font-semibold text-gray-900 flex items-center gap-2">
<span
className="inline-block w-2 h-2 rounded-full"
style={{
background:
itemLoans.length === 0 ? "#34d399" : "#60a5fa",
}}
></span>
{item.name}
</div>
{itemLoans.length === 0 ? (
<div className="text-green-500 text-xs mt-1 font-medium">
Verfügbar
</div>
) : (
<ul className="mt-2 space-y-1">
{itemLoans.map((loan, idx) => (
<li
key={idx}
className="text-xs text-blue-800 bg-blue-100/60 p-1 rounded"
>
<span className="font-bold">{loan.username}</span>
<span className="ml-2">
{formatDateTime(loan.start)} {" "}
{formatDateTime(loan.end)}
</span>
<span className="ml-2 text-gray-400">
({loan.loanCode})
</span>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
<div className="mt-10 text-xs text-gray-400 flex items-center gap-4">
<span className="inline-block w-3 h-3 bg-green-400 rounded-full mr-1"></span>
Verfügbar
<span className="inline-block w-3 h-3 bg-blue-400 rounded-full ml-4 mr-1"></span>
Verliehen
</div>
</aside>
{/* Hauptbereich */}
<main className="flex-1 flex flex-col items-center py-14 px-4">
<header className="mb-12">
<h1 className="text-4xl font-extrabold text-blue-800 tracking-tight drop-shadow-sm">
Gegenstand ausleihen
</h1>
<p className="text-blue-400 mt-2 text-lg font-medium">
Schnell und unkompliziert Equipment reservieren
</p>
</header>
<div className="bg-white/90 shadow-2xl rounded-3xl p-10 w-full max-w-xl ring-1 ring-blue-100">
{step === 1 && (
<form
className="space-y-6"
onSubmit={(e) => {
e.preventDefault();
setAvailableItems(getAvailableItems(startDate, endDate));
setStep(2);
}}
>
<h2 className="text-xl font-bold mb-2 text-blue-700">
1. Zeitraum wählen
</h2>
<div>
<label className="block font-medium mb-1 text-blue-900">
Startdatum
</label>
<input
type="datetime-local"
className="w-full border border-blue-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-400 focus:outline-none"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
required
/>
</div>
<div>
<label className="block font-medium mb-1 text-blue-900">
Enddatum
</label>
<input
type="datetime-local"
className="w-full border border-blue-200 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-400 focus:outline-none"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
required
min={startDate}
/>
</div>
<button
type="submit"
className="w-full bg-gradient-to-r from-blue-600 to-blue-400 hover:from-blue-700 hover:to-blue-500 text-white font-bold py-2 px-4 rounded-xl shadow transition disabled:bg-gray-300"
disabled={!startDate || !endDate || endDate <= startDate}
>
Verfügbare Gegenstände anzeigen
</button>
</form>
)}
{step === 2 && (
<div>
<h2 className="text-xl font-bold mb-6 text-blue-700">
2. Gegenstand auswählen
</h2>
{availableItems.length === 0 ? (
<div className="text-red-600 mb-8 font-medium text-center">
Keine Gegenstände verfügbar für diesen Zeitraum.
</div>
) : (
<ul className="mb-8 space-y-3">
{availableItems.map((item) => (
<li
key={item.id}
className={`flex justify-between items-center p-4 rounded-xl shadow-sm border ${
selectedItem === item.id
? "bg-blue-100 border-blue-400"
: "bg-green-50 border-green-100 hover:bg-blue-50"
} transition`}
>
<span className="font-medium text-lg">{item.name}</span>
<button
className={`px-4 py-1 rounded-lg bg-gradient-to-r from-blue-500 to-blue-400 text-white text-sm font-semibold shadow hover:from-blue-600 hover:to-blue-500 ${
selectedItem === item.id ? "ring-2 ring-blue-400" : ""
}`}
onClick={() => setSelectedItem(item.id)}
>
{selectedItem === item.id ? "Ausgewählt" : "Auswählen"}
</button>
</li>
))}
</ul>
)}
<div className="flex justify-between">
<button
className="px-5 py-2 bg-gray-100 text-gray-600 rounded-xl hover:bg-gray-200 font-semibold shadow"
onClick={() => setStep(1)}
>
Zurück
</button>
<button
className="px-5 py-2 bg-gradient-to-r from-blue-600 to-blue-400 text-white rounded-xl hover:from-blue-700 hover:to-blue-500 font-bold shadow transition disabled:bg-gray-300"
disabled={selectedItem === null}
onClick={() => setStep(3)}
>
Ausleihen
</button>
</div>
</div>
)}
{step === 3 && (
<div className="mt-2 p-8 bg-blue-50/80 border border-blue-200 rounded-2xl text-center shadow-lg">
<h3 className="font-extrabold text-blue-700 mb-3 text-2xl">
Ausleihe bestätigt!
</h3>
<p className="mb-2 text-lg">
Ihr Ausleih-Code lautet:{" "}
<span className="font-mono text-2xl text-blue-900 bg-white px-2 py-1 rounded shadow">
{loanCode}
</span>
</p>
<p className="mt-2 text-blue-600 text-sm">
Bitte merken Sie sich diesen Code, um das Schließfach zu öffnen.
</p>
<button
className="mt-8 px-6 py-2 bg-gradient-to-r from-blue-600 to-blue-400 text-white rounded-xl hover:from-blue-700 hover:to-blue-500 font-bold shadow"
onClick={() => {
setStep(1);
setStartDate("");
setEndDate("");
setSelectedItem(null);
}}
>
Neue Ausleihe
</button>
</div>
)}
</div>
</main>
</div>
);
}
// Hilfsfunktion: Datumsformatierung (z.B. 01.01.2025 08:00)
function formatDateTime(dt: string) {
const d = new Date(dt);
return (
d.toLocaleDateString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}) +
" " +
d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" })
);
}

View File

@@ -1,20 +0,0 @@
[
{
"id": 1,
"title": "Mock Book 1",
"author": "Author 1",
"description": "Description for Mock Book 1"
},
{
"id": 2,
"title": "Mock Book 2",
"author": "Author 2",
"description": "Description for Mock Book 2"
},
{
"id": 3,
"title": "Mock Book 3",
"author": "Author 3",
"description": "Description for Mock Book 3"
}
]

View File

@@ -1,7 +1,73 @@
# Borrow System
**You have reached the `debian12` branch.**
![React](https://img.shields.io/badge/React-20232A?logo=react&logoColor=61DAFB)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6?logo=typescript&logoColor=white)
![Vite](https://img.shields.io/badge/Vite-646CFF?logo=vite&logoColor=white)
![TailwindCSS](https://img.shields.io/badge/Tailwind_CSS-38B2AC?logo=tailwind-css&logoColor=white)
![Node.js](https://img.shields.io/badge/Node.js-339933?logo=node.js&logoColor=white)
![Express](https://img.shields.io/badge/Express-000000?logo=express&logoColor=white)
![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white)
![Docker](https://img.shields.io/badge/Docker-2496ED?logo=docker&logoColor=white)
![JWT](https://img.shields.io/badge/JWT-000000?logo=jsonwebtokens&logoColor=white)
Here you will find the source code of exactly the application that I have hosted.
A small fullstack system to log in, view available items, reserve them for a time window, and manage personal loans.
The main branch or the branch that I am developing on, is the `dev` branch.
- Frontend: React + TypeScript + Vite + Tailwind CSS
- Backend: Node.js + Express + MySQL + JWT (jose)
- Orchestration: Docker Compose (backend + MySQL)
## Contents
- Frontend: [frontend/](frontend)
- Vite/Tailwind config: [frontend/vite.config.ts](frontend/vite.config.ts), [frontend/tailwind.config.js](frontend/tailwind.config.js)
- App entry: [frontend/src/main.tsx](frontend/src/main.tsx), [frontend/src/App.tsx](frontend/src/App.tsx)
- UI: [frontend/src/layout/Layout.tsx](frontend/src/layout/Layout.tsx), [frontend/src/components](frontend/src/components)
- Data/utilities: [frontend/src/utils/fetchData.ts](frontend/src/utils/fetchData.ts), [frontend/src/utils/userHandler.ts](frontend/src/utils/userHandler.ts), [frontend/src/utils/toastify.ts](frontend/src/utils/toastify.ts)
- Backend: [backend/](backend)
- Server: [backend/server.js](backend/server.js)
- Routes: [backend/routes/api.js](backend/routes/api.js), [backend/routes/apiV2.js](backend/routes/apiV2.js)
- DB + services: [backend/services/database.js](backend/services/database.js), [backend/services/tokenService.js](backend/services/tokenService.js)
- Schema/seed: [backend/scheme.sql](backend/scheme.sql)
- Docs: [docs/](docs)
- API docs (see below): [docs/backend_API_docs/README.md](docs/backend_API_docs/README.md)
## Features (highlevel)
- Auth via JWT (login -> token cookie) using the backend route in [backend/routes/api.js](backend/routes/api.js).
- After login, the app loads items, loans, and user loans and keeps them in localStorage.
- Choose a date range to fetch borrowable items, select items, and create a loan.
- Manage personal loans list (and delete a loan).
Key frontend utilities:
- [`utils.fetchData.fetchAllData`](frontend/src/utils/fetchData.ts): loads items, loans, and user loans after login.
- [`utils.fetchData.getBorrowableItems`](frontend/src/utils/fetchData.ts): fetches borrowable items for the selected time range.
- [`utils.userHandler.createLoan`](frontend/src/utils/userHandler.ts): creates a new loan for selected items.
- [`utils.userHandler.handleDeleteLoan`](frontend/src/utils/userHandler.ts): deletes a loan and syncs local state.
- [`utils.toastify.myToast`](frontend/src/utils/toastify.ts): toast notifications.
UI flow (main screens):
- Period selection: [frontend/src/components/Form1.tsx](frontend/src/components/Form1.tsx)
- Borrowable items + selection: [frontend/src/components/Form2.tsx](frontend/src/components/Form2.tsx)
- User loans table: [frontend/src/components/Form4.tsx](frontend/src/components/Form4.tsx)
## Development
- Scripts: see [frontend/package.json](frontend/package.json) and [backend/package.json](backend/package.json)
- Frontend: `npm run dev`, `npm run build`, `npm run preview`, `npm run lint`
- Backend: `npm start`
- Linting: ESLint configured via [frontend/eslint.config.js](frontend/eslint.config.js)
- TypeScript configs: [frontend/tsconfig.app.json](frontend/tsconfig.app.json), [frontend/tsconfig.node.json](frontend/tsconfig.node.json)
## Configuration notes
- Vite/Tailwind integration via [frontend/vite.config.ts](frontend/vite.config.ts) and `@tailwindcss/vite`; CSS entry uses `@import "tailwindcss"` in [frontend/src/index.css](frontend/src/index.css).
- Toasts wired in [frontend/src/main.tsx](frontend/src/main.tsx) with `react-toastify`.
- Local state is stored in `localStorage` keys: `allItems`, `allLoans`, `userLoans`, `borrowableItems`. Crosscomponent updates are signaled via window events from [`utils.fetchData`](frontend/src/utils/fetchData.ts).
## API documentation
Refer to the dedicated API docs:
`docs/backend_API_docs/README.md`

View File

@@ -1,12 +1,19 @@
FROM node:20-alpine
FROM node:18 as builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 8003
FROM nginx:alpine AS runner
CMD ["npm", "run", "dev"]
WORKDIR /usr/share/nginx/html
COPY --from=builder /app/dist .
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/svg+xml" href="/user-star.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Admin panel</title>
</head>

18
admin/nginx.conf Normal file
View File

@@ -0,0 +1,18 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 1y;
access_log off;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
}

View File

@@ -12,6 +12,7 @@
"@emotion/react": "^11.14.0",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.85.5",
"jotai": "^2.15.0",
"js-cookie": "^3.0.5",
"lucide-react": "^0.539.0",
"next-themes": "^0.4.6",
@@ -4420,6 +4421,35 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/jotai": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/jotai/-/jotai-2.15.0.tgz",
"integrity": "sha512-nbp/6jN2Ftxgw0VwoVnOg0m5qYM1rVcfvij+MZx99Z5IK13eGve9FJoCwGv+17JvVthTjhSmNtT5e1coJnr6aw==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@babel/core": ">=7.0.0",
"@babel/template": ">=7.0.0",
"@types/react": ">=17.0.0",
"react": ">=17.0.0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"@babel/template": {
"optional": true
},
"@types/react": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
@@ -5645,13 +5675,13 @@
}
},
"node_modules/tinyglobby": {
"version": "0.2.14",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz",
"integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==",
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"license": "MIT",
"dependencies": {
"fdir": "^6.4.4",
"picomatch": "^4.0.2"
"fdir": "^6.5.0",
"picomatch": "^4.0.3"
},
"engines": {
"node": ">=12.0.0"
@@ -5849,9 +5879,9 @@
}
},
"node_modules/vite": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",
"integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==",
"version": "7.1.11",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz",
"integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
@@ -5859,7 +5889,7 @@
"picomatch": "^4.0.3",
"postcss": "^8.5.6",
"rollup": "^4.43.0",
"tinyglobby": "^0.2.14"
"tinyglobby": "^0.2.15"
},
"bin": {
"vite": "bin/vite.js"

View File

@@ -14,6 +14,7 @@
"@emotion/react": "^11.14.0",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-query": "^5.85.5",
"jotai": "^2.15.0",
"js-cookie": "^3.0.5",
"lucide-react": "^0.539.0",
"next-themes": "^0.4.6",

View File

@@ -0,0 +1 @@
<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-user-star-icon lucide-user-star"><path d="M16.051 12.616a1 1 0 0 1 1.909.024l.737 1.452a1 1 0 0 0 .737.535l1.634.256a1 1 0 0 1 .588 1.806l-1.172 1.168a1 1 0 0 0-.282.866l.259 1.613a1 1 0 0 1-1.541 1.134l-1.465-.75a1 1 0 0 0-.912 0l-1.465.75a1 1 0 0 1-1.539-1.133l.258-1.613a1 1 0 0 0-.282-.866l-1.156-1.153a1 1 0 0 1 .572-1.822l1.633-.256a1 1 0 0 0 .737-.535z"/><path d="M8 15H7a4 4 0 0 0-4 4v2"/><circle cx="10" cy="7" r="4"/></svg>

After

Width:  |  Height:  |  Size: 635 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -4,9 +4,7 @@ import Layout from "./Layout/Layout";
function App() {
return (
<>
<Layout>
<p></p>
</Layout>
<Layout />
</>
);
}

View File

@@ -1,10 +1,12 @@
import React from "react";
import { useState } from "react";
import { useEffect } from "react";
import { Box, Heading, Text, Flex, Button } from "@chakra-ui/react";
import Sidebar from "./Sidebar";
import UserTable from "../components/UserTable";
import ItemTable from "../components/ItemTable";
import LoanTable from "../components/LoanTable";
import APIKeyTable from "@/components/APIKeyTable";
import { MoveLeft } from "lucide-react";
type DashboardProps = {
@@ -16,6 +18,24 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
const [activeView, setActiveView] = useState("");
useEffect(() => {
if (typeof window === "undefined") return;
const raw = window.location.pathname.slice(1);
if (raw) {
setActiveView(decodeURIComponent(raw));
}
}, []);
// Sync URL when activeView changes, without reloading
useEffect(() => {
if (typeof window === "undefined") return;
if (!activeView) return;
const desired = `/${encodeURIComponent(activeView)}`;
if (window.location.pathname !== desired) {
window.history.replaceState(null, "", desired);
}
}, [activeView]);
return (
<Flex h="100vh">
<Sidebar
@@ -23,6 +43,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
viewGegenstaende={() => setActiveView("Gegenstände")}
viewSchliessfaecher={() => setActiveView("Schließfächer")}
viewUser={() => setActiveView("User")}
viewAPI={() => setActiveView("API")}
/>
<Box flex="1" display="flex" flexDirection="column">
<Flex
@@ -66,6 +87,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
{activeView === "User" && <UserTable />}
{activeView === "Ausleihen" && <LoanTable />}
{activeView === "Gegenstände" && <ItemTable />}
{activeView === "API" && <APIKeyTable />}
</Box>
</Box>
</Flex>

View File

@@ -3,23 +3,23 @@ import { useEffect } from "react";
import Dashboard from "./Dashboard";
import Login from "./Login";
import Cookies from "js-cookie";
import { API_BASE } from "@/config/api.config";
type LayoutProps = {
children: React.ReactNode;
};
const Layout: React.FC<LayoutProps> = ({ children }) => {
const Layout: React.FC = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false);
useEffect(() => {
if (Cookies.get("token")) {
const verifyToken = async () => {
const response = await fetch("https://backend.insta.the1s.de/api/verifyToken", {
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
const response = await fetch(
`${API_BASE}/api/admin/user-mgmt/verify-token`,
{
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
}
);
if (response.ok) {
setIsLoggedIn(true);
} else {
@@ -34,20 +34,18 @@ const Layout: React.FC<LayoutProps> = ({ children }) => {
const handleLogout = () => {
Cookies.remove("token");
window.location.pathname = "/";
setIsLoggedIn(false);
};
return (
<>
<main>
{isLoggedIn ? (
<Dashboard onLogout={() => handleLogout()} />
) : (
<Login onSuccess={() => setIsLoggedIn(true)} />
)}
</main>
{children}
</>
<main>
{isLoggedIn ? (
<Dashboard onLogout={() => handleLogout()} />
) : (
<Login onSuccess={() => setIsLoggedIn(true)} />
)}
</main>
);
};

View File

@@ -23,39 +23,45 @@ const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
};
return (
<Card.Root maxW="sm">
<Card.Header>
<Card.Title>Login</Card.Title>
<Card.Description>
Bitte unten Ihre Admin Zugangsdaten eingeben.
</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>username</Field.Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</Field.Root>
<Field.Root>
<Field.Label>password</Field.Label>
<Input
type="password"
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} />}
<Button onClick={() => handleLogin()} variant="solid">
Login
</Button>
</Card.Footer>
</Card.Root>
<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>Login</Card.Title>
<Card.Description>
Bitte unten Ihre Admin Zugangsdaten eingeben.
</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>username</Field.Label>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</Field.Root>
<Field.Root>
<Field.Label>password</Field.Label>
<Input
type="password"
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} />
)}
<Button type="submit" onClick={() => handleLogin()} variant="solid">
Login
</Button>
</Card.Footer>
</Card.Root>
</form>
</div>
);
};

View File

@@ -1,23 +1,38 @@
import React from "react";
import { useEffect, useState } from "react";
import { Box, Flex, VStack, Heading, Text, Link } from "@chakra-ui/react";
import { API_BASE } from "@/config/api.config";
type SidebarProps = {
viewAusleihen: () => void;
viewGegenstaende: () => void;
viewSchliessfaecher: () => void;
viewUser: () => void;
viewAPI: () => void;
};
const Sidebar: React.FC<SidebarProps> = ({
viewAusleihen,
viewGegenstaende,
viewSchliessfaecher,
viewUser,
viewAPI,
}) => {
const [info, setInfo] = useState<any>(null);
const fetchInfo = async () => {
const response = await fetch(`${API_BASE}/`);
const data = await response.json();
setInfo(data);
};
useEffect(() => {
fetchInfo();
}, []);
return (
<Box
as="aside"
w="260px"
w="180px"
minH="100vh"
bg="gray.800"
color="gray.100"
@@ -59,10 +74,45 @@ const Sidebar: React.FC<SidebarProps> = ({
>
Gegenstände
</Link>
<Link
px={3}
py={2}
rounded="md"
_hover={{ bg: "gray.700", textDecoration: "none" }}
onClick={viewAPI}
>
API Keys
</Link>
</VStack>
<Box mt="auto" pt={8} fontSize="xs" color="gray.500">
<Text>&copy; Made with by Theis Gaedigk</Text>
<Text mb={2}>&copy; Made with by Theis Gaedigk</Text>
{info ? (
<Flex gap={2} wrap="wrap">
<Box
as="span"
px={2}
py={0.5}
rounded="full"
bg="gray.700"
color="gray.200"
>
Panel {info?.["admin-panel-info"]?.version ?? "—"}
</Box>
<Box
as="span"
px={2}
py={0.5}
rounded="full"
bg="gray.700"
color="gray.200"
>
Backend {info?.["backend-info"]?.version ?? "—"}
</Box>
</Flex>
) : (
<Text color="gray.600">Lade Versionsinfos</Text>
)}
</Box>
</Flex>
</Box>

View File

@@ -0,0 +1,3 @@
import { atom } from "jotai";
export const testAtom = atom<number>(0);

View File

@@ -0,0 +1,36 @@
# How to use Atoms
Atoms are the fundamental building blocks of state management in this system. They represent individual pieces of state that can be shared and manipulated across different components.
You can also name it global state.
## Creating an Atom
to create an atom you have to declare an atom like this:
```ts
import { atom } from 'jotai';
export const NAME_OF_YOUR_ATOM = atom<type_of_your_atom>(initial_value);
```
In this project we declare all atoms in the `States/Atoms.tsx`file. Which you can find above this README file.
## Using an Atom
To use an atom in your component, you can use the `useAtom` hook provided by Jotai. Here's an example of how to use an atom in a React component:
```tsx
import { useAtom } from 'jotai';
import { NAME_OF_YOUR_ATOM } from '@/States/Atoms';
const MyComponent = () => {
const [value, setValue] = useAtom(NAME_OF_YOUR_ATOM);
return (
<div>
<p>Current value: {value}</p>
<button onClick={() => setValue(newValue)}>Update Value</button>
</div>
);
};
```
As you can see, you can use `useAtom` like `useState` but the state is global. In this example `value` is the current state of the atom, and `setValue` is a function to update the state, which is also known as the setter function.

View File

@@ -0,0 +1,225 @@
import React from "react";
import {
Table,
Spinner,
Text,
VStack,
Button,
HStack,
IconButton,
Heading,
} from "@chakra-ui/react";
import { Tooltip } from "@/components/ui/tooltip";
import MyAlert from "./myChakra/MyAlert";
import { Trash2, RefreshCcwDot, CirclePlus } from "lucide-react";
import Cookies from "js-cookie";
import { useState, useEffect } from "react";
import { deleteAPKey } from "@/utils/userActions";
import AddAPIKey from "./AddAPIKey";
import { formatDateTime } from "@/utils/userFuncs";
import { API_BASE } from "@/config/api.config";
type Items = {
id: number;
api_key: string;
entry_name: string;
entry_created_at: string;
last_used_at: string | null;
};
const APIKeyTable: 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 [addAPIForm, setAddAPIForm] = useState(false);
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/api-data/get-api-keys`,
{
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
}
);
const data = await response.json();
console.log(data);
return 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 (
<>
{/* Action toolbar */}
<HStack
mb={4}
gap={3}
justify="flex-start"
align="center"
flexWrap="wrap"
>
<Tooltip content="API Keys neu laden" openDelay={300}>
<IconButton
aria-label="Refresh API Keys"
size="sm"
variant="outline"
rounded="md"
shadow="sm"
_hover={{ shadow: "md", transform: "translateY(-2px)" }}
_active={{ transform: "translateY(0)" }}
onClick={() => setReload(!reload)}
>
<RefreshCcwDot size={18} />
</IconButton>
</Tooltip>
<Tooltip content="Neuen API Key hinzufügen" openDelay={300}>
<Button
size="sm"
colorPalette="teal"
variant="solid"
rounded="md"
fontWeight="semibold"
shadow="sm"
_hover={{ shadow: "md", bg: "colorPalette.600" }}
_active={{ bg: "colorPalette.700" }}
onClick={() => {
setAddAPIForm(true);
}}
>
<CirclePlus size={18} style={{ marginRight: 6 }} />
Neuen API Key hinzufügen
</Button>
</Tooltip>
</HStack>
{/* End action toolbar */}
<Heading marginBottom={4} size="2xl">
API Keys
</Heading>
{isError && (
<MyAlert
status={errorStatus}
description={errorDsc}
title={errorMessage}
/>
)}
{isLoading && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">Loading...</Text>
</VStack>
)}
{addAPIForm && (
<AddAPIKey
onClose={() => {
setAddAPIForm(false);
setReload(!reload);
}}
alert={setError}
/>
)}
<Table.Root
size="sm"
striped
w="100%"
// table-layout: auto => Spaltenbreite nach Content; volle Breite nutzen
style={{ tableLayout: "auto" }}
>
<Table.Header>
<Table.Row>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>API Key</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Name</strong>
</Table.ColumnHeader>
<Table.ColumnHeader whiteSpace="nowrap">
<strong>Eintrag erstellt am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader whiteSpace="nowrap">
<strong>Zuletzt benutzt am</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 whiteSpace="nowrap">{item.id}</Table.Cell>
<Table.Cell fontFamily="mono">{item.api_key}</Table.Cell>
<Table.Cell>{item.entry_name}</Table.Cell>
<Table.Cell whiteSpace="nowrap">
{formatDateTime(item.entry_created_at)}
</Table.Cell>
<Table.Cell whiteSpace="nowrap">
{!item.last_used_at
? "Nie benutzt"
: formatDateTime(item.last_used_at)}
</Table.Cell>
<Table.Cell whiteSpace="nowrap">
<Button
onClick={() =>
deleteAPKey(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>
</>
);
};
export default APIKeyTable;

View File

@@ -0,0 +1,105 @@
import React from "react";
import {
Button,
Card,
Field,
Input,
Stack,
InputGroup,
Span,
} from "@chakra-ui/react";
import { createAPIentry } from "@/utils/userActions";
import { useState } from "react";
type AddAPIKeyProps = {
onClose: () => void;
alert: (
status: "success" | "error",
message: string,
description: string
) => void;
};
const AddAPIKey: React.FC<AddAPIKeyProps> = ({ onClose, alert }) => {
const [value, setValue] = useState("");
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<Card.Root maxW="sm">
<Card.Header>
<Card.Title>Neuen API Key erstellen</Card.Title>
<Card.Description>
Füllen Sie das folgende Formular aus, um einen API Key zu erstellen.
</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<InputGroup
endElement={
<Span color="fg.muted" textStyle="xs">
{value.length} / {8}
</Span>
}
>
<Input
placeholder="Er muss 8 zahlen lang sein"
value={value}
id="apiKey"
maxLength={8}
onChange={(e) => {
setValue(e.currentTarget.value.slice(0, 8));
}}
/>
</InputGroup>
<Field.Root>
<Field.Label>Name</Field.Label>
<Input id="name" type="text" />
</Field.Root>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
<Button variant="outline" onClick={onClose}>
Abbrechen
</Button>
<Button
variant="solid"
onClick={async () => {
const apiKey =
(
document.getElementById("apiKey") as HTMLInputElement
)?.value.trim() || "";
const name =
(
document.getElementById("name") as HTMLInputElement
)?.value.trim() || "";
if (!apiKey || !name) return;
const res = await createAPIentry(apiKey, name);
if (res.success) {
alert(
"success",
"API Key erstellt",
"Der API Key wurde erfolgreich erstellt."
);
onClose();
} else {
alert(
"error",
"Fehler beim Erstellen des API Keys",
res.message ||
"Beim Erstellen des API Keys ist ein Fehler aufgetreten. (frontend bug)"
);
onClose();
}
}}
>
Erstellen
</Button>
</Card.Footer>
</Card.Root>
</div>
);
};
export default AddAPIKey;

View File

@@ -1,5 +1,13 @@
import React from "react";
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
import {
Button,
Card,
Field,
Input,
Stack,
Text,
Checkbox,
} from "@chakra-ui/react";
import { createUser } from "@/utils/userActions";
type AddFormProps = {
@@ -12,66 +20,128 @@ type AddFormProps = {
};
const AddForm: React.FC<AddFormProps> = ({ onClose, alert }) => {
const [admin, setAdmin] = React.useState(false);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<Card.Root maxW="sm">
<Card.Header>
<Card.Title>Neuen Nutzer erstellen</Card.Title>
<Card.Description>
Füllen Sie das folgende Formular aus, um einen Nutzer zu erstellen.
</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>Username</Field.Label>
<Input id="username" />
</Field.Root>
<Field.Root>
<Field.Label>Password</Field.Label>
<Input id="password" type="password" />
</Field.Root>
<Field.Root>
<Field.Label>Role</Field.Label>
<Input id="role" type="number" />
</Field.Root>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
<Button variant="outline" onClick={onClose}>
Abbrechen
</Button>
<Button
variant="solid"
onClick={async () => {
const username =
(
document.getElementById("username") as HTMLInputElement
)?.value.trim() || "";
const password =
(document.getElementById("password") as HTMLInputElement)
?.value || "";
const role = Number(
(document.getElementById("role") as HTMLInputElement)?.value
);
<form
onSubmit={(e) => {
e.preventDefault();
}}
>
<Card.Root maxW="sm">
<Card.Header>
<Card.Title>Neuen Nutzer erstellen</Card.Title>
<Card.Description>
Füllen Sie das folgende Formular aus, um einen Nutzer zu
erstellen.
</Card.Description>
</Card.Header>
if (!username || !password || Number.isNaN(role)) return;
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>Benutzername</Field.Label>
<Input id="username" />
</Field.Root>
<Field.Root>
<Field.Label>Passwort</Field.Label>
<Input id="password" type="password" />
</Field.Root>
<Field.Root>
<Field.Label>Vorname</Field.Label>
<Input id="firstname" />
</Field.Root>
<Field.Root>
<Field.Label>Nachname</Field.Label>
<Input id="lastname" />
</Field.Root>
<Field.Root>
<Field.Label>E-Mail</Field.Label>
<Input id="email" type="email" />
</Field.Root>
const res = await createUser(username, role, password);
if (res.success) {
alert(
"success",
"Nutzer erstellt",
"Der Nutzer wurde erfolgreich erstellt."
{/* Kontrollierte Checkbox */}
<Checkbox.Root
checked={admin}
onCheckedChange={(e: any) => setAdmin(Boolean(e?.checked ?? e))}
>
<Checkbox.HiddenInput />
<Checkbox.Control />
<Checkbox.Label>Admin</Checkbox.Label>
</Checkbox.Root>
<Field.Root>
<Field.Label>Rolle</Field.Label>
<Input id="role" type="number" />
</Field.Root>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
<Text>Der Benutzername kann nicht mehr geändert werden.</Text>
<Button variant="outline" onClick={onClose}>
Abbrechen
</Button>
<Button
variant="solid"
type="submit"
onClick={async () => {
const username =
(
document.getElementById("username") as HTMLInputElement
)?.value.trim() || "";
const password =
(document.getElementById("password") as HTMLInputElement)
?.value || "";
const role = Number(
(document.getElementById("role") as HTMLInputElement)?.value
);
onClose();
}
}}
>
Erstellen
</Button>
</Card.Footer>
</Card.Root>
const firstname =
(
document.getElementById("firstname") as HTMLInputElement
)?.value.trim() || "";
const lastname =
(
document.getElementById("lastname") as HTMLInputElement
)?.value.trim() || "";
const email =
(
document.getElementById("email") as HTMLInputElement
)?.value.trim() || "";
// admin kommt jetzt zuverlässig aus dem State
const res = await createUser(
username,
role,
password,
firstname,
lastname,
email,
admin
);
if (res.success) {
alert(
"success",
"Nutzer erstellt",
"Der Nutzer wurde erfolgreich erstellt."
);
onClose();
} else {
alert(
"error",
"Fehler beim Erstellen des Nutzers",
"Es gab einen Fehler beim Erstellen des Nutzers. Vielleicht gibt es bereits einen Nutzer mit diesem Benutzernamen."
);
onClose();
}
}}
>
Erstellen
</Button>
</Card.Footer>
</Card.Root>
</form>
</div>
);
};

View File

@@ -25,15 +25,19 @@ const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>Gegenstandsname</Field.Label>
<Field.Label>Gegenstandsname (muss einzigartig sein)</Field.Label>
<Input id="item_name" placeholder="z.B. Laptop" />
</Field.Root>
<Field.Root>
<Field.Label>Schließfachnummer</Field.Label>
<Input id="safe_nr" placeholder="Nummer 1 - 6" />
</Field.Root>
<Field.Root>
<Field.Label>Ausleih-Berechtigung (Rolle)</Field.Label>
<Input
id="can_borrow_role"
type="number"
placeholder="Zahl (z.B. 2)"
placeholder="Zahl (1 - 6)"
/>
</Field.Root>
</Stack>
@@ -53,10 +57,15 @@ const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
(document.getElementById("can_borrow_role") as HTMLInputElement)
?.value
);
const safeNrValue = (
document.getElementById("safe_nr") as HTMLInputElement
)?.value.trim();
const safeNr = safeNrValue === "" ? null : safeNrValue;
if (!name || Number.isNaN(role)) return;
const res = await createItem(name, role);
const res = await createItem(name, role, safeNr);
if (res.success) {
alert(
"success",
@@ -68,8 +77,10 @@ const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
alert(
"error",
"Fehler",
"Der Gegenstand konnte nicht erstellt werden."
res.message ||
"Der Gegenstand konnte nicht erstellt werden. (frontend bug)"
);
onClose();
}
}}
>

View File

@@ -0,0 +1,122 @@
import React from "react";
import { Button, Card, Field, Input, Stack, Alert } from "@chakra-ui/react";
import { changePW } from "@/utils/userActions";
import { useState } from "react";
type ChangePWformProps = {
onClose: () => void;
alert: (
status: "success" | "error",
message: string,
description: string
) => void;
username: string;
};
const ChangePWform: React.FC<ChangePWformProps> = ({
onClose,
alert,
username,
}) => {
const [showSubAlert, setShowSubAlert] = useState(false);
const [subAlertMessage, setSubAlertMessage] = useState("");
const subAlert = (message: string) => {
setSubAlertMessage(message);
setShowSubAlert(true);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<Card.Root maxW="sm">
<Card.Header>
<Card.Title>Passwort ändern</Card.Title>
<Card.Description>
Füllen Sie das folgende Formular aus, um das Passwort zu ändern.
</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Field.Root>
<Field.Label>Neues Passwort</Field.Label>
<Input
id="new_password"
type="password"
placeholder="Neues Passwort"
/>
</Field.Root>
<Field.Root>
<Field.Label>Neues Passwort widerholen</Field.Label>
<Input
id="confirm_new_password"
type="password"
placeholder="Wiederholen Sie das neue Passwort"
/>
</Field.Root>
</Stack>
</Card.Body>
<Card.Footer gap="2">
<Stack w="full" gap="3">
<Stack direction="row" justify="flex-end" gap="2">
<Button variant="outline" onClick={onClose}>
Abbrechen
</Button>
<Button
variant="solid"
onClick={async () => {
const newPassword =
(
document.getElementById(
"new_password"
) as HTMLInputElement
)?.value.trim() || "";
const confirmNewPassword =
(
document.getElementById(
"confirm_new_password"
) as HTMLInputElement
)?.value.trim() || "";
if (!newPassword || newPassword !== confirmNewPassword) {
subAlert("Passwörter stimmen nicht überein!");
return;
}
const res = await changePW(newPassword, username);
if (res.success) {
alert(
"success",
"Passwort geändert",
"Das Passwort wurde erfolgreich geändert."
);
onClose();
} else {
alert(
"error",
"Fehler",
"Das Passwort konnte nicht geändert werden."
);
onClose();
}
}}
>
Ändern
</Button>
</Stack>
{showSubAlert && (
<Alert.Root status="error">
<Alert.Indicator />
<Alert.Content>
<Alert.Title>{subAlertMessage}</Alert.Title>
</Alert.Content>
</Alert.Root>
)}
</Stack>
</Card.Footer>
</Card.Root>
</div>
);
};
export default ChangePWform;

View File

@@ -9,7 +9,7 @@ import {
IconButton,
Heading,
Icon,
Tag,
Input,
} from "@chakra-ui/react";
import { Tooltip } from "@/components/ui/tooltip";
import MyAlert from "./myChakra/MyAlert";
@@ -19,18 +19,30 @@ import {
CirclePlus,
CheckCircle2,
XCircle,
Save,
} from "lucide-react";
import Cookies from "js-cookie";
import { useState, useEffect } from "react";
import { deleteItem } from "@/utils/userActions";
import {
deleteItem,
handleEditItems,
changeSafeState,
} from "@/utils/userActions";
import AddItemForm from "./AddItemForm";
import { formatDateTime } from "@/utils/userFuncs";
import { API_BASE } from "@/config/api.config";
type Items = {
id: number;
item_name: string;
can_borrow_role: string;
inSafe: boolean;
in_safe: boolean;
safe_nr: string;
door_key: string;
entry_created_at: string;
entry_updated_at: string;
last_borrowed_person: string | null;
currently_borrowing: string | null;
};
const ItemTable: React.FC = () => {
@@ -43,6 +55,30 @@ const ItemTable: React.FC = () => {
const [reload, setReload] = useState(false);
const [addForm, setAddForm] = useState(false);
const handleItemNameChange = (id: number, value: string) => {
setItems((prev) =>
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))
);
};
const handleLockerNumberChange = (id: number, value: string) => {
setItems((prev) =>
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))
);
};
const setError = (
status: "error" | "success",
message: string,
@@ -59,12 +95,15 @@ const ItemTable: React.FC = () => {
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch("https://backend.insta.the1s.de/api/allItems", {
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
const response = await fetch(
`${API_BASE}/api/admin/item-data/all-items`,
{
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
}
);
const data = await response.json();
return data;
} catch (error) {
@@ -126,7 +165,7 @@ const ItemTable: React.FC = () => {
</HStack>
{/* End action toolbar */}
<Heading marginBottom={4} size="md">
<Heading marginBottom={4} size="2xl">
Gegenstände
</Heading>
{isError && (
@@ -152,112 +191,185 @@ const ItemTable: React.FC = () => {
/>
)}
<Table.Root size="sm" striped>
<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>
<strong>Eintrag erstellt am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<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>{item.item_name}</Table.Cell>
<Table.Cell>{item.can_borrow_role}</Table.Cell>
<Table.Cell>
{item.inSafe ? (
<Tag.Root
size="md"
bg="green.500"
color="white"
px={4}
py={1.5}
rounded="full"
display="inline-flex"
alignItems="center"
gap={2}
shadow="sm"
_hover={{ shadow: "md" }}
>
<Icon as={CheckCircle2} boxSize={4} />
<Text
as="span"
fontSize="xs"
letterSpacing="wide"
textTransform="uppercase"
>
Yes
</Text>
</Tag.Root>
) : (
<Tag.Root
size="md"
bg="red.500"
color="white"
px={4}
py={1.5}
rounded="full"
display="inline-flex"
alignItems="center"
gap={2}
shadow="sm"
_hover={{ shadow: "md" }}
>
<Icon as={XCircle} boxSize={4} />
<Text
as="span"
fontSize="xs"
letterSpacing="wide"
textTransform="uppercase"
>
No
</Text>
</Tag.Root>
)}
</Table.Cell>
<Table.Cell>{item.entry_created_at}</Table.Cell>
<Table.Cell>
<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>
{/* make table fill available width, like UserTable */}
{!isLoading && (
<Table.Root size="sm" striped w="100%" style={{ tableLayout: "auto" }}>
<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>
<strong>Schließfachnummer</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<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>
<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>
<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>
)}
<Text>* LaP = Letzte ausleihende Person</Text>
<Text>** Dav = Derzeit ausgeliehen von</Text>
</>
);
};

View File

@@ -17,6 +17,7 @@ import MyAlert from "./myChakra/MyAlert";
import { formatDateTime } from "@/utils/userFuncs";
import { Trash2, RefreshCcwDot } from "lucide-react";
import { deleteLoan } from "@/utils/userActions";
import { API_BASE } from "@/config/api.config";
const LoanTable: React.FC = () => {
const [items, setItems] = useState<Loan[]>([]);
@@ -49,18 +50,23 @@ const LoanTable: React.FC = () => {
returned_date: string;
created_at: string;
loaned_items_name: string[];
deleted: boolean;
note: string;
};
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch("https://backend.insta.the1s.de/api/allLoans", {
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
const response = await fetch(
`${API_BASE}/api/admin/loan-data/all-loans`,
{
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
}
);
const data = await response.json();
return data;
} catch (error) {
@@ -78,10 +84,6 @@ const LoanTable: React.FC = () => {
return (
<>
<Heading marginBottom={4} size="md">
Ausleihen
</Heading>
{/* Action toolbar */}
<HStack
mb={4}
@@ -107,6 +109,14 @@ const LoanTable: React.FC = () => {
</HStack>
{/* End action toolbar */}
<Heading marginBottom={4} size="2xl">
Ausleihen
</Heading>
<Text>
Die Ausleihen die rot sind, wurden gelöscht und sind nur für den Admin
sichtbar.
</Text>
{isError && (
<MyAlert
status={errorStatus}
@@ -151,6 +161,9 @@ const LoanTable: React.FC = () => {
<Table.ColumnHeader>
<strong>Ausgeliehene Artikel</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Notiz</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Aktionen</strong>
</Table.ColumnHeader>
@@ -158,7 +171,7 @@ const LoanTable: React.FC = () => {
</Table.Header>
<Table.Body>
{items.map((item) => (
<Table.Row key={item.id}>
<Table.Row color={item.deleted ? "red" : "white"} key={item.id}>
<Table.Cell>{item.id}</Table.Cell>
<Table.Cell>{item.username}</Table.Cell>
<Table.Cell>
@@ -170,6 +183,7 @@ const LoanTable: React.FC = () => {
<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={() =>

View File

@@ -10,6 +10,7 @@ import {
HStack,
IconButton,
Heading,
Switch, // neu
} from "@chakra-ui/react";
import { Tooltip } from "@/components/ui/tooltip";
import { fetchUserData } from "@/utils/fetcher";
@@ -18,13 +19,18 @@ import { handleDelete, handleEdit } from "@/utils/userActions";
import MyAlert from "./myChakra/MyAlert";
import AddForm from "./AddForm";
import { formatDateTime } from "@/utils/userFuncs";
import ChangePWform from "./ChangePWform";
type User = {
id: number;
username: string;
password: string;
role: string;
first_name: string;
last_name: string;
email: string;
is_admin: boolean;
role: number;
entry_created_at: string;
entry_updated_at: string;
};
const UserTable: React.FC = () => {
@@ -36,6 +42,8 @@ const UserTable: React.FC = () => {
const [errorDsc, setErrorDsc] = useState("");
const [reload, setReload] = useState(false);
const [addForm, setAddForm] = useState(false);
const [changePWform, setChangePWform] = useState(false);
const [changeUsr, setChangeUsr] = useState("");
const setError = (
status: "error" | "success",
@@ -49,20 +57,35 @@ const UserTable: React.FC = () => {
setIsError(true);
};
const handleInputChange = (userId: number, field: string, value: string) => {
const handleInputChange = (userId: number, field: string, value: any) => {
setUsers((prevUsers) =>
prevUsers.map((user) =>
user.id === userId ? { ...user, [field]: value } : user
user.id === userId
? {
...user,
[field]:
field === "role"
? Number(value)
: field === "is_admin"
? value === true || value === "true" || value === 1
: value,
}
: user
)
);
};
const handlePasswordChange = (username: string) => {
setChangeUsr(username);
setChangePWform(true);
};
useEffect(() => {
const fetchUsers = async () => {
setIsLoading(true);
try {
const data = await fetchUserData();
console.log("user api response", data);
console.log(data);
if (Array.isArray(data)) {
setUsers(data);
} else {
@@ -136,9 +159,19 @@ const UserTable: React.FC = () => {
</HStack>
{/* End action toolbar */}
<Heading marginBottom={4} size="md">
<Heading marginBottom={4} size="2xl">
Benutzer
</Heading>
{changePWform && (
<ChangePWform
onClose={() => {
setChangePWform(false);
setReload(!reload);
}}
alert={setError}
username={changeUsr}
/>
)}
{isError && (
<MyAlert
status={errorStatus}
@@ -162,25 +195,45 @@ const UserTable: React.FC = () => {
</VStack>
)}
{!isLoading && (
<Table.Root size="sm" striped>
<Table.Root
size="sm"
striped
w="100%"
style={{ tableLayout: "auto" }} // Spalten nach Content
>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Benutzername</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Passwort</strong>
<strong>Vorname</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Nachname</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>E-Mail</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Admin</strong>
</Table.ColumnHeader>
<Table.ColumnHeader whiteSpace="nowrap">
<strong>Passwort ändern</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Rolle</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<Table.ColumnHeader whiteSpace="nowrap">
<strong>Eintrag erstellt am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<Table.ColumnHeader whiteSpace="nowrap">
<strong>Eintrag aktualisiert am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Aktionen</strong>
</Table.ColumnHeader>
</Table.Row>
@@ -188,41 +241,86 @@ const UserTable: React.FC = () => {
<Table.Body>
{users.map((user) => (
<Table.Row key={user.id}>
<Table.Cell>{user.id}</Table.Cell>
<Table.Cell whiteSpace="nowrap">{user.id}</Table.Cell>
<Table.Cell>{user.username}</Table.Cell>
<Table.Cell>
<Input
size="sm"
value={user.first_name ?? ""}
onChange={(e) =>
handleInputChange(user.id, "username", e.target.value)
handleInputChange(user.id, "first_name", e.target.value)
}
value={user.username}
/>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
value={user.last_name ?? ""}
onChange={(e) =>
handleInputChange(user.id, "password", e.target.value)
handleInputChange(user.id, "last_name", e.target.value)
}
value={user.password}
/>
</Table.Cell>
<Table.Cell>
<Input
type="email"
size="sm"
value={user.email ?? ""}
onChange={(e) =>
handleInputChange(user.id, "email", e.target.value)
}
/>
</Table.Cell>
<Table.Cell whiteSpace="nowrap">
<Switch.Root
size="sm"
checked={!!user.is_admin}
onCheckedChange={(d) =>
handleInputChange(user.id, "is_admin", d.checked)
}
aria-label="Adminrechte umschalten"
>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
<Switch.HiddenInput />
</Switch.Root>
</Table.Cell>
<Table.Cell whiteSpace="nowrap">
<Button
size="sm"
onClick={() => handlePasswordChange(user.username)}
>
Passwort ändern
</Button>
</Table.Cell>
<Table.Cell whiteSpace="nowrap">
<Input
type="number"
size="sm"
onChange={(e) =>
handleInputChange(user.id, "role", e.target.value)
}
value={user.role}
width="70px"
/>
</Table.Cell>
<Table.Cell>{formatDateTime(user.entry_created_at)}</Table.Cell>
<Table.Cell>
<Table.Cell whiteSpace="nowrap">
{formatDateTime(user.entry_created_at)}
</Table.Cell>
<Table.Cell whiteSpace="nowrap">
{formatDateTime(user.entry_updated_at)}
</Table.Cell>
<Table.Cell whiteSpace="nowrap">
<Button
onClick={() =>
handleEdit(
user.id,
user.username,
user.role,
user.password
user.first_name,
user.last_name,
user.email,
user.is_admin,
Number(user.role)
).then((response) => {
if (response.success) {
setError(

View File

@@ -0,0 +1,4 @@
export const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";

View File

@@ -1,7 +1,8 @@
import Cookies from "js-cookie";
import { API_BASE } from "@/config/api.config";
export const fetchUserData = async () => {
const response = await fetch("https://backend.insta.the1s.de/api/allUsers", {
const response = await fetch(`${API_BASE}/api/admin/user-data/users`, {
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},

View File

@@ -1,4 +1,5 @@
import Cookies from "js-cookie";
import { API_BASE } from "@/config/api.config";
export type LoginSuccess = { success: true };
export type LoginFailure = {
@@ -13,12 +14,20 @@ export const loginFunc = async (
password: string
): Promise<LoginResult> => {
try {
const response = await fetch("https://backend.insta.the1s.de/api/loginAdmin", {
const response = await fetch(`${API_BASE}/api/admin/user-mgmt/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }),
});
if (response.status === 403) {
return {
success: false,
message: "Login failed!",
description: "You are not an admin user.",
};
}
if (!response.ok) {
return {
success: false,
@@ -34,6 +43,7 @@ export const loginFunc = async (
return { success: true };
} catch (error) {
console.error("Error logging in:", error);
return {
success: false,
message: "Login failed!",

View File

@@ -1,9 +1,10 @@
import Cookies from "js-cookie";
import { API_BASE } from "@/config/api.config";
export const handleDelete = async (userId: number) => {
try {
const response = await fetch(
`https://backend.insta.the1s.de/api/deleteUser/${userId}`,
`${API_BASE}/api/admin/user-data/delete-user/${userId}`,
{
method: "DELETE",
headers: {
@@ -23,20 +24,28 @@ export const handleDelete = async (userId: number) => {
export const handleEdit = async (
userId: number,
username: string,
role: string,
password: string
first_name: string,
last_name: string,
email: string,
is_admin: boolean,
role: number
) => {
try {
const response = await fetch(
`https://backend.insta.the1s.de/api/editUser/${userId}`,
`${API_BASE}/api/admin/user-data/edit-user/${userId}`,
{
method: "PUT",
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ username, role, password }),
body: JSON.stringify({
first_name,
last_name,
role,
email,
is_admin,
}),
}
);
if (!response.ok) {
@@ -52,17 +61,32 @@ export const handleEdit = async (
export const createUser = async (
username: string,
role: number,
password: string
password: string,
first_name: string,
last_name: string,
email: string,
isAdmin: boolean
) => {
try {
const response = await fetch(`https://backend.insta.the1s.de/api/createUser`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ username, role, password }),
});
const response = await fetch(
`${API_BASE}/api/admin/user-data/create-user`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({
username,
role,
password,
isAdmin,
email,
first_name,
last_name,
}),
}
);
if (!response.ok) {
throw new Error("Failed to create user");
}
@@ -73,10 +97,33 @@ export const createUser = async (
}
};
export const changePW = async (newPassword: string, username: string) => {
try {
const response = await fetch(
`${API_BASE}/api/admin/user-data/change-password`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ username, password: newPassword }),
}
);
if (!response.ok) {
throw new Error("Failed to change password");
}
return { success: true };
} catch (error) {
console.error("Error changing password:", error);
return { success: false };
}
};
export const deleteLoan = async (loanId: number) => {
try {
const response = await fetch(
`https://backend.insta.the1s.de/api/deleteLoan/${loanId}`,
`${API_BASE}/api/admin/loan-data/delete-loan/${loanId}`,
{
method: "DELETE",
headers: {
@@ -97,7 +144,7 @@ export const deleteLoan = async (loanId: number) => {
export const deleteItem = async (itemId: number) => {
try {
const response = await fetch(
`https://backend.insta.the1s.de/api/deleteItem/${itemId}`,
`${API_BASE}/api/admin/item-data/delete-item/${itemId}`,
{
method: "DELETE",
headers: {
@@ -117,19 +164,28 @@ export const deleteItem = async (itemId: number) => {
export const createItem = async (
item_name: string,
can_borrow_role: number
can_borrow_role: number,
lockerNumber: string | null
) => {
console.log(JSON.stringify({ item_name, can_borrow_role, lockerNumber }));
try {
const response = await fetch(`https://backend.insta.the1s.de/api/createItem`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ item_name, can_borrow_role }),
});
const response = await fetch(
`${API_BASE}/api/admin/item-data/create-item`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ item_name, can_borrow_role, lockerNumber }),
}
);
if (!response.ok) {
throw new Error("Failed to create item");
return {
success: false,
message:
"Fehler beim Erstellen des Gegenstands. Der Name des Gegenstandes und die Schließfachnummer dürfen nicht mehrmals vergeben werden.",
};
}
return { success: true };
} catch (error) {
@@ -137,3 +193,101 @@ export const createItem = async (
return { success: false };
}
};
export const handleEditItems = async (
itemId: number,
item_name: string,
safe_nr: string | null,
door_key: string | null,
can_borrow_role: string
) => {
try {
const response = await fetch(
`${API_BASE}/api/admin/item-data/edit-item/${itemId}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ item_name, safe_nr, door_key, can_borrow_role }),
}
);
if (!response.ok) {
throw new Error("Failed to edit item");
}
return { success: true };
} catch (error) {
console.error("Error editing item:", error);
return { success: false };
}
};
export const changeSafeState = async (itemId: number) => {
try {
const response = await fetch(
`${API_BASE}/api/admin/item-data/change-safe-state/${itemId}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
}
);
if (!response.ok) {
throw new Error("Failed to change safe state");
}
return { success: true };
} catch (error) {
console.error("Error changing safe state:", error);
return { success: false };
}
};
export const createAPIentry = async (apiKey: string, name: string) => {
try {
const response = await fetch(
`${API_BASE}/api/admin/api-data/create-api-key`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ apiKey, entryName: name }),
}
);
if (!response.ok) {
return {
success: false,
message:
"Fehler beim Erstellen des API Keys. Achten Sie darauf, dass alle Felder ausgefüllt sind und der API Key nicht doppelt vergeben wird.",
};
}
return { success: true };
} catch (error) {
console.error("Error creating API entry:", error);
return { success: false };
}
};
export const deleteAPKey = async (apiKeyId: number) => {
try {
const response = await fetch(
`${API_BASE}/api/admin/api-data/delete-api-key/${apiKeyId}`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
}
);
if (!response.ok) {
throw new Error("Failed to delete API key");
}
return { success: true };
} catch (error) {
console.error("Error deleting API key:", error);
return { success: false };
}
};

View File

@@ -1,14 +1,7 @@
export const formatDateTime = (value: string | null | undefined) => {
if (!value) return "N/A";
const inpDate = new Date(value);
if (isNaN(inpDate.getTime())) return "N/A";
return (
inpDate.toLocaleString(undefined, {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
}) + " Uhr"
);
const m = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!m) return "N/A";
const [, y, M, d, h, min] = m;
return `${d}.${M}.${y} ${h}:${min} Uhr`;
};

View File

@@ -29,7 +29,8 @@
"@/*": ["./src/*"]
},
"forceConsistentCasingInFileNames": true
"forceConsistentCasingInFileNames": true,
"ignoreDeprecations": "5.0"
},
"include": ["src"]
}

View File

@@ -8,13 +8,9 @@ export default defineConfig({
plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
server: {
host: "0.0.0.0",
allowedHosts: ["admin.insta.the1s.de"],
port: 8103,
watch: { usePolling: true },
hmr: {
host: "admin.insta.the1s.de",
port: 8103,
protocol: "wss",
port: 8003,
watch: {
usePolling: true,
},
},
});

View File

@@ -1,279 +0,0 @@
import express from "express";
import {
loginFunc,
getItemsFromDatabase,
getLoansFromDatabase,
getUserLoansFromDatabase,
deleteLoanFromDatabase,
getBorrowableItemsFromDatabase,
createLoanInDatabase,
onTake,
loginAdmin,
onReturn,
getAllUsers,
deleteUserID,
handleEdit,
createUser,
getAllLoans,
getAllItems,
deleteItemID,
createItem,
} from "../services/database.js";
import { authenticate, generateToken } from "../services/tokenService.js";
const router = express.Router();
router.post("/login", async (req, res) => {
const result = await loginFunc(req.body.username, req.body.password);
if (result.success) {
const token = await generateToken({
username: result.data.username,
role: result.data.role,
});
res.status(200).json({ message: "Login successful", token });
} else {
res.status(401).json({ message: "Invalid credentials" });
}
});
router.get("/items", authenticate, async (req, res) => {
console.log(req);
const result = await getItemsFromDatabase(req.user.role);
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch items" });
}
});
router.get("/loans", authenticate, async (req, res) => {
const result = await getLoansFromDatabase();
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch loans" });
}
});
router.get("/userLoans", authenticate, async (req, res) => {
const result = await getUserLoansFromDatabase(req.user.username);
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch user loans" });
}
});
router.delete("/deleteLoan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await deleteLoanFromDatabase(loanId);
if (result.success) {
res.status(200).json({ message: "Loan deleted successfully" });
} else {
res.status(500).json({ message: "Failed to delete loan" });
}
});
router.post("/borrowableItems", 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" });
}
});
router.post("/takeLoan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await onTake(loanId);
if (result.success) {
res.status(200).json({ message: "Loan taken successfully" });
} else {
res.status(500).json({ message: "Failed to take loan" });
}
});
router.post("/returnLoan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await onReturn(loanId);
if (result.success) {
res.status(200).json({ message: "Loan returned successfully" });
} else {
res.status(500).json({ message: "Failed to return loan" });
}
});
router.post("/createLoan", authenticate, async (req, res) => {
try {
const { items, startDate, endDate } = req.body || {};
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", " ");
// 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" });
}
const result = await createLoanInDatabase(
req.user.username,
start,
end,
itemIds
);
if (result.success) {
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" });
}
});
// Admin panel functions
router.post("/loginAdmin", async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
return res
.status(400)
.json({ message: "Username and password are required" });
}
const result = await loginAdmin(username, password);
if (result.success) {
const token = await generateToken({
username: result.data.username,
role: result.data.role,
});
return res.status(200).json({
message: "Login successful",
first_name: result.data.first_name,
token,
});
}
return res.status(401).json({ message: "Invalid credentials" });
});
router.get("/allUsers", authenticate, async (req, res) => {
const result = await getAllUsers();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch users" });
});
router.delete("/deleteUser/:id", authenticate, async (req, res) => {
const userId = req.params.id;
const result = await deleteUserID(userId);
if (result.success) {
return res.status(200).json({ message: "User deleted successfully" });
}
return res.status(500).json({ message: "Failed to delete user" });
});
router.get("/verifyToken", authenticate, async (req, res) => {
res.status(200).json({ message: "Token is valid" });
});
router.put("/editUser/:id", authenticate, async (req, res) => {
const userId = req.params.id;
const { username, role, password } = req.body || {};
const result = await handleEdit(userId, username, role, password);
if (result.success) {
return res.status(200).json({ message: "User edited successfully" });
}
return res.status(500).json({ message: "Failed to edit user" });
});
router.post("/createUser", authenticate, async (req, res) => {
const { username, role, password } = req.body || {};
const result = await createUser(username, role, password);
if (result.success) {
return res.status(201).json({ message: "User created successfully" });
}
return res.status(500).json({ message: "Failed to create user" });
});
router.get("/allLoans", authenticate, async (req, res) => {
const result = await getAllLoans();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch loans" });
});
router.get("/allItems", authenticate, async (req, res) => {
const result = await getAllItems();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch items" });
});
router.delete("/deleteItem/:id", authenticate, async (req, res) => {
const itemId = req.params.id;
const result = await deleteItemID(itemId);
if (result.success) {
return res.status(200).json({ message: "Item deleted successfully" });
}
return res.status(500).json({ message: "Failed to delete item" });
});
router.post("/createItem", authenticate, async (req, res) => {
const { item_name, can_borrow_role } = req.body || {};
const result = await createItem(item_name, can_borrow_role);
if (result.success) {
return res.status(201).json({ message: "Item created successfully" });
}
return res.status(500).json({ message: "Failed to create item" });
});
export default router;

View File

@@ -1,93 +0,0 @@
import express from "express";
import dotenv from "dotenv";
import {
getItemsFromDatabaseV2,
changeInSafeStateV2,
setReturnDateV2,
setTakeDateV2,
getLoanByCodeV2,
} from "../services/database.js";
dotenv.config();
const router = express.Router();
// Route for API to get ALL items from the database
router.get("/items/:key", async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) {
const result = await getItemsFromDatabaseV2();
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to fetch items" });
}
} else {
res.status(403).json({ message: "Access denied" });
}
});
// Route for API to control the position of an item
router.post("/controlInSafe/:key/:itemId/:state", async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) {
const itemId = req.params.itemId;
const state = req.params.state;
if (state === "1" || state === "0") {
const result = await changeInSafeStateV2(itemId, state);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to update item state" });
}
} else {
res.status(400).json({ message: "Invalid state value" });
}
} else {
res.status(403).json({ message: "Access denied" });
}
});
router.get("/getLoanByCode/:key/:loan_code", async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) {
const loan_code = req.params.loan_code;
const result = await getLoanByCodeV2(loan_code);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(404).json({ message: "Loan not found" });
}
}
});
// Route for API to set the return date
router.post("/setReturnDate/:key/:loan_code", async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) {
const loanCode = req.params.loan_code;
const result = await setReturnDateV2(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set return date" });
}
} else {
res.status(403).json({ message: "Access denied" });
}
});
// Route for API to set the take away date
router.post("/setTakeDate/:key/:loan_code", async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) {
const loanCode = req.params.loan_code;
const result = await setTakeDateV2(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set take date" });
}
} else {
res.status(403).json({ message: "Access denied" });
}
});
export default router;

View File

@@ -1,91 +0,0 @@
-- All necessary tables for the borrowing system
-- IMPORTANT: You need mySQL version 8.0 or newer!
CREATE TABLE `users` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(100) NOT NULL,
`password` varchar(255) NOT NULL,
`role` int DEFAULT NULL,
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
);
CREATE TABLE `admins` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(100) NOT NULL,
`password` varchar(255) NOT NULL,
`first_name` varchar(255) NOT NULL,
`last_name` varchar(255) NOT NULL,
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `username` (`username`)
);
CREATE TABLE `loans` (
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(100) NOT NULL,
`loan_code` int NOT NULL,
`start_date` timestamp NOT NULL,
`end_date` timestamp NOT NULL,
`take_date` timestamp NULL DEFAULT NULL,
`returned_date` timestamp NULL DEFAULT NULL,
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
`loaned_items_id` json NOT NULL DEFAULT ('[]'),
`loaned_items_name` json NOT NULL DEFAULT ('[]'),
PRIMARY KEY (`id`),
UNIQUE KEY `loan_code` (`loan_code`)
);
CREATE TABLE `items` (
`id` int NOT NULL AUTO_INCREMENT,
`item_name` varchar(255) NOT NULL,
`can_borrow_role` INT NOT NULL,
`inSafe` tinyint(1) NOT NULL DEFAULT '1',
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `item_name` (`item_name`)
);
CREATE TABLE `lockers` (
`id` int NOT NULL AUTO_INCREMENT,
`item` varchar(255) NOT NULL,
`locker_number` int NOT NULL,
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `item` (`item`),
UNIQUE KEY `locker_number` (`locker_number`)
);
INSERT INTO `items` (`item_name`, `can_borrow_role`, `inSafe`) VALUES
('DJI 1er Mikro', 4, 1),
('DJI 2er Mikro 1', 4, 1),
('DJI 2er Mikro 2', 4, 1),
('Rode Richt Mikrofon', 2, 1),
('Kamera Stativ', 1, 0),
('SONY Kamera - inkl. Akkus und Objektiv', 1, 1),
('MacBook inkl. Adapter', 2, 0),
('SD Karten', 3, 0),
('Kameragimbal', 1, 0),
('ATEM MINI PRO', 1, 1),
('Handygimbal', 4, 0),
('Kameralüfter', 1, 1),
('Kleine Kamera 1 - inkl. Objektiv', 2, 1),
('Kleine Kamera 2 - inkl. Objektiv', 2, 1);
INSERT INTO `lockers` (`item`, `locker_number`) VALUES
('DJI 1er Mikro', 1),
('DJI 2er Mikro 1', 2),
('DJI 2er Mikro 2', 3),
('Rode Richt Mikrofon', 4),
('Kamera Stativ', 5),
('SONY Kamera - inkl. Akkus und Objektiv', 6),
('MacBook inkl. Adapter', 7),
('SD Karten', 8),
('Kameragimbal', 9),
('ATEM MINI PRO', 10),
('Handygimbal', 11),
('Kameralüfter', 12),
('Kleine Kamera 1 - inkl. Objektiv', 13),
('Kleine Kamera 2 - inkl. Objektiv', 14);

View File

@@ -1,32 +0,0 @@
import express from "express";
import cors from "cors";
import env from "dotenv";
import apiRouter from "./routes/api.js";
import apiRouterV2 from "./routes/apiV2.js";
env.config();
const app = express();
const port = 8102;
app.use(cors());
// Increase body size limits to support large CSV JSON payloads
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
app.set("view engine", "ejs");
app.use(express.json({ limit: "10mb" }));
app.use("/api", apiRouter);
app.use("/apiV2", apiRouterV2);
app.get("/", (req, res) => {
res.render("index.ejs");
});
app.listen(port, () => {
console.log(`Server is running on port: ${port}`);
});
// error handling code
app.use((err, req, res, next) => {
// Log the error stack and send a generic error response
console.error(err.stack);
res.status(500).send("Something broke!");
});

View File

@@ -1,388 +0,0 @@
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,
port: process.env.DB_PORT,
})
.promise();
export const loginFunc = async (username, password) => {
const [result] = await pool.query(
"SELECT * FROM users WHERE username = ? AND password = ?",
[username, password]
);
if (result.length > 0) return { success: true, data: result[0] };
return { success: false };
};
export const getItemsFromDatabaseV2 = async () => {
const [rows] = await pool.query("SELECT * FROM items;");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const getLoanByCodeV2 = async (loan_code) => {
const [result] = await pool.query(
"SELECT * FROM loans WHERE loan_code = ?;",
[loan_code]
);
if (result.length > 0) {
return { success: true, data: result[0] };
}
return { success: false };
};
export const changeInSafeStateV2 = async (itemId, state) => {
const [result] = await pool.query(
"UPDATE items SET inSafe = ? WHERE id = ?",
[state, itemId]
);
if (result.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const setReturnDateV2 = async (loanCode) => {
const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
[loanCode]
);
if (result.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const setTakeDateV2 = async (loanCode) => {
const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
[loanCode]
);
if (result.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const getItemsFromDatabase = async (role) => {
const sql =
role == 0
? "SELECT * FROM items;"
: "SELECT * FROM items WHERE can_borrow_role >= ?";
const params = role == 0 ? [] : [role];
const [rows] = await pool.query(sql, params);
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const getLoansFromDatabase = async () => {
const [result] = await pool.query("SELECT * FROM loans;");
if (result.length > 0) {
return { success: true, data: result };
}
return { success: false };
};
export const getUserLoansFromDatabase = async (username) => {
const [result] = await pool.query("SELECT * FROM loans WHERE username = ?;", [
username,
]);
if (result.length > 0) {
return { success: true, data: result };
} else if (result.length == 0) {
return { success: true, data: "No loans found for this user" };
} else {
return { success: false };
}
};
export const deleteLoanFromDatabase = async (loanId) => {
const [result] = await pool.query("DELETE FROM loans WHERE id = ?;", [
loanId,
]);
if (result.affectedRows > 0) {
return { success: true };
} else {
return { success: false };
}
};
export const getBorrowableItemsFromDatabase = async (
startDate,
endDate,
role = 0
) => {
// Overlap if: loan.start < end AND effective_end > start
// effective_end is returned_date if set, otherwise end_date
const hasRoleFilter = Number(role) > 0;
const sql = `
SELECT i.*
FROM items i
WHERE ${hasRoleFilter ? "i.can_borrow_role >= ? AND " : ""}NOT EXISTS (
SELECT 1
FROM loans l
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
WHERE jt.item_id = i.id
AND l.start_date < ?
AND COALESCE(l.returned_date, l.end_date) > ?
);
`;
const params = hasRoleFilter
? [role, endDate, startDate]
: [endDate, startDate];
const [rows] = await pool.query(sql, params);
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const createLoanInDatabase = async (
username,
startDate,
endDate,
itemIds
) => {
if (!username)
return { success: false, code: "BAD_REQUEST", message: "Missing username" };
if (!Array.isArray(itemIds) || itemIds.length === 0)
return {
success: false,
code: "BAD_REQUEST",
message: "No items provided",
};
if (!startDate || !endDate)
return { success: false, code: "BAD_REQUEST", message: "Missing dates" };
const start = new Date(startDate);
const end = new Date(endDate);
if (
!(start instanceof Date) ||
isNaN(start.getTime()) ||
!(end instanceof Date) ||
isNaN(end.getTime()) ||
start >= end
) {
return {
success: false,
code: "BAD_REQUEST",
message: "Invalid date range",
};
}
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
// Ensure all items exist and collect names
const [itemsRows] = await conn.query(
"SELECT id, item_name FROM items WHERE id IN (?)",
[itemIds]
);
if (!itemsRows || itemsRows.length !== itemIds.length) {
await conn.rollback();
return {
success: false,
code: "BAD_REQUEST",
message: "One or more items not found",
};
}
const itemNames = itemIds
.map(
(id) => itemsRows.find((r) => Number(r.id) === Number(id))?.item_name
)
.filter(Boolean);
// Check availability (no overlap with existing loans)
const [confRows] = await conn.query(
`
SELECT COUNT(*) AS conflicts
FROM loans l
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
ON TRUE
WHERE jt.item_id IN (?)
AND l.start_date < ?
AND COALESCE(l.returned_date, l.end_date) > ?
`,
[itemIds, end, start]
);
if (confRows?.[0]?.conflicts > 0) {
await conn.rollback();
return {
success: false,
code: "CONFLICT",
message: "One or more items are not available in the selected period",
};
}
// Generate unique loan_code (retry a few times)
let loanCode = null;
for (let i = 0; i < 6; i++) {
const candidate = Math.floor(1000 + Math.random() * 900000); // 4-6 digits
const [exists] = await conn.query(
"SELECT 1 FROM loans WHERE loan_code = ? LIMIT 1",
[candidate]
);
if (exists.length === 0) {
loanCode = candidate;
break;
}
}
if (!loanCode) {
await conn.rollback();
return {
success: false,
code: "SERVER_ERROR",
message: "Failed to generate unique loan code",
};
}
// Insert loan
const [insertRes] = await conn.query(
`
INSERT INTO loans (username, loan_code, start_date, end_date, loaned_items_id, loaned_items_name)
VALUES (?, ?, ?, ?, CAST(? AS JSON), CAST(? AS JSON))
`,
[
username,
loanCode,
// Use DATETIME/TIMESTAMP friendly format
new Date(start).toISOString().slice(0, 19).replace("T", " "),
new Date(end).toISOString().slice(0, 19).replace("T", " "),
JSON.stringify(itemIds.map((n) => Number(n))),
JSON.stringify(itemNames),
]
);
await conn.commit();
return {
success: true,
data: {
id: insertRes.insertId,
loan_code: loanCode,
username,
start_date: start,
end_date: end,
items: itemIds,
item_names: itemNames,
},
};
} catch (err) {
await conn.rollback();
console.error("createLoanInDatabase error:", err);
return {
success: false,
code: "SERVER_ERROR",
message: "Failed to create loan",
};
} finally {
conn.release();
}
};
// These functions are only temporary, and will be deleted when the full bin is set up.
export const onTake = async (loanId) => {
const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE id = ?",
[loanId]
);
if (result.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const onReturn = async (loanId) => {
const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE id = ?",
[loanId]
);
if (result.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const loginAdmin = async (username, password) => {
const [result] = await pool.query(
"SELECT * FROM admins WHERE username = ? AND password = ?",
[username, password]
);
if (result.length > 0) return { success: true, data: result[0] };
return { success: false };
};
export const getAllUsers = async () => {
const [result] = await pool.query("SELECT * FROM users");
if (result.length > 0) return { success: true, data: result };
return { success: false };
};
export const deleteUserID = async (userId) => {
const [result] = await pool.query("DELETE FROM users WHERE id = ?", [userId]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const handleEdit = async (userId, username, role, password) => {
const [result] = await pool.query(
"UPDATE users SET username = ?, role = ?, password = ? WHERE id = ?",
[username, role, password, userId]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const createUser = async (username, role, password) => {
const [result] = await pool.query(
"INSERT INTO users (username, role, password) VALUES (?, ?, ?)",
[username, role, password]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const getAllLoans = async () => {
const [result] = await pool.query("SELECT * FROM loans");
if (result.length > 0) return { success: true, data: result };
return { success: false };
};
export const getAllItems = async () => {
const [result] = await pool.query("SELECT * FROM items");
if (result.length > 0) return { success: true, data: result };
return { success: false };
};
export const deleteItemID = async (itemId) => {
const [result] = await pool.query("DELETE FROM items WHERE id = ?", [itemId]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const createItem = async (item_name, can_borrow_role) => {
const [result] = await pool.query(
"INSERT INTO items (item_name, can_borrow_role) VALUES (?, ?)",
[item_name, can_borrow_role]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};

View File

@@ -1,26 +0,0 @@
import { SignJWT, jwtVerify } from "jose";
import env from "dotenv";
env.config();
const secret = new TextEncoder().encode(process.env.SECRET_KEY);
export async function generateToken(payload) {
const newToken = await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("2h") // Token valid for 2 hours
.sign(secret);
console.log("Generated token: ", newToken);
return newToken;
}
export async function authenticate(req, res, next) {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1]; // Bearer <token>
if (token == null) return res.sendStatus(401); // No token present
const { payload } = await jwtVerify(token, secret);
req.user = payload;
next();
}

View File

@@ -1,12 +1,12 @@
FROM node:20-alpine
ENV NODE_ENV=production
WORKDIR /backend
COPY package*.json ./
RUN npm install
RUN npm ci --omit=dev
COPY . .
EXPOSE 8102
EXPOSE 8004
CMD ["npm", "start"]

11
backendV2/info.json Normal file
View File

@@ -0,0 +1,11 @@
{
"backend-info": {
"version": "v2.0.1 (dev)"
},
"frontend-info": {
"version": "v2.0 (dev)"
},
"admin-panel-info": {
"version": "v1.3 (dev)"
}
}

View File

@@ -1,11 +1,11 @@
{
"name": "backend",
"name": "backendv2",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backend",
"name": "backendv2",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
@@ -14,7 +14,8 @@
"ejs": "^3.1.10",
"express": "^5.1.0",
"jose": "^6.0.12",
"mysql2": "^3.14.3"
"mysql2": "^3.14.3",
"nodemailer": "^7.0.6"
}
},
"node_modules/accepts": {
@@ -171,9 +172,9 @@
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -206,9 +207,9 @@
}
},
"node_modules/dotenv": {
"version": "17.2.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.1.tgz",
"integrity": "sha512-kQhDYKZecqnM0fCnzI5eIv5L4cAe/iRI+HqMbO/hbRdTAeXDG+M9FjipUxNfbARuEg4iHIbhnhs78BCHNbSxEQ==",
"version": "17.2.3",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
"integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -565,9 +566,9 @@
}
},
"node_modules/jose": {
"version": "6.0.12",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.0.12.tgz",
"integrity": "sha512-T8xypXs8CpmiIi78k0E+Lk7T2zlK4zDyg+o1CZ4AkOHgDg98ogdP2BeZ61lTFKFyoEwJ9RgAgN+SdM3iPgNonQ==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.0.tgz",
"integrity": "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@@ -673,15 +674,15 @@
"license": "MIT"
},
"node_modules/mysql2": {
"version": "3.14.3",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.14.3.tgz",
"integrity": "sha512-fD6MLV8XJ1KiNFIF0bS7Msl8eZyhlTDCDl75ajU5SJtpdx9ZPEACulJcqJWr1Y8OYyxsFc4j3+nflpmhxCU5aQ==",
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz",
"integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.1",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.6.3",
"iconv-lite": "^0.7.0",
"long": "^5.2.1",
"lru.min": "^1.0.0",
"named-placeholders": "^1.1.3",
@@ -692,6 +693,22 @@
"node": ">= 8.0"
}
},
"node_modules/mysql2/node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"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/named-placeholders": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz",
@@ -713,6 +730,15 @@
"node": ">= 0.6"
}
},
"node_modules/nodemailer": {
"version": "7.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz",
"integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -765,12 +791,13 @@
}
},
"node_modules/path-to-regexp": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
"integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"license": "MIT",
"engines": {
"node": ">=16"
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/picocolors": {
@@ -817,18 +844,34 @@
}
},
"node_modules/raw-body": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
"integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz",
"integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.6.3",
"iconv-lite": "0.7.0",
"unpipe": "1.0.0"
},
"engines": {
"node": ">= 0.8"
"node": ">= 0.10"
}
},
"node_modules/raw-body/node_modules/iconv-lite": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"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/router": {

View File

@@ -1,7 +1,7 @@
{
"name": "backend",
"name": "backendv2",
"version": "1.0.0",
"main": "index.js",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
@@ -16,6 +16,7 @@
"ejs": "^3.1.10",
"express": "^5.1.0",
"jose": "^6.0.12",
"mysql2": "^3.14.3"
"mysql2": "^3.14.3",
"nodemailer": "^7.0.6"
}
}
}

View File

@@ -0,0 +1,41 @@
import express from "express";
import { authenticateAdmin } from "../../services/authentication.js";
const router = express.Router();
import dotenv from "dotenv";
dotenv.config();
// database funcs import
import {
getAllApiKeys,
createAPIentry,
deleteAPKey,
} from "./database/apiDataMgmt.database.js";
router.get("/get-api-keys", authenticateAdmin, async (req, res) => {
const result = await getAllApiKeys();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to retrieve API keys" });
});
router.post("/create-api-key", authenticateAdmin, async (req, res) => {
const apiKey = req.body.apiKey;
const entryName = req.body.entryName;
const result = await createAPIentry(apiKey, entryName);
if (result.success) {
return res.status(201).json({ message: "API key created successfully" });
}
return res.status(500).json({ message: "Failed to create API key" });
});
router.delete("/delete-api-key/:id", authenticateAdmin, async (req, res) => {
const apiKeyId = req.params.id;
const result = await deleteAPKey(apiKeyId);
if (result.success) {
return res.status(200).json({ message: "API key deleted successfully" });
}
return res.status(500).json({ message: "Failed to delete API key" });
});
export default router;

View File

@@ -0,0 +1,37 @@
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 getAllApiKeys = async () => {
const [rows] = await pool.query("SELECT * FROM apiKeys");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const createAPIentry = async (apiKey, entryName) => {
const [result] = await pool.query(
"INSERT INTO apiKeys (api_key, entry_name) VALUES (?, ?)",
[apiKey, entryName]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const deleteAPKey = async (apiKeyId) => {
const [result] = await pool.query("DELETE FROM apiKeys WHERE id = ?", [
apiKeyId,
]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};

View File

@@ -0,0 +1,82 @@
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 getAllItems = async () => {
const [result] = await pool.query("SELECT * FROM items");
if (result.length > 0) return { success: true, data: result };
return { success: false };
};
export const deleteItemById = async (itemId) => {
const [result] = await pool.query("DELETE FROM items WHERE id = ?", [itemId]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const createItem = async (item_name, can_borrow_role, lockerNumber) => {
const [result] = await pool.query(
"INSERT INTO items (item_name, can_borrow_role, in_safe, safe_nr) VALUES (?, ?, ?, ?)",
[item_name, can_borrow_role, true, lockerNumber]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const editItemById = async (
itemId,
item_name,
can_borrow_role,
safe_nr,
door_key
) => {
let newSafeNr;
if (safe_nr === null || safe_nr === "") {
newSafeNr = null;
} else {
newSafeNr = safe_nr;
}
const [result] = await pool.query(
"UPDATE items SET item_name = ?, can_borrow_role = ?, safe_nr = ?, door_key = ?, entry_updated_at = NOW() WHERE id = ?",
[item_name, can_borrow_role, newSafeNr, door_key, itemId]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const changeSafeState = async (itemId) => {
const currentState = await pool.query(
"SELECT in_safe FROM items WHERE id = ?",
[itemId]
);
if (currentState[0].length === 0) {
return { success: false };
}
if (currentState[0][0].in_safe) {
const [result] = await pool.query(
"UPDATE items SET in_safe = false WHERE id = ?",
[itemId]
);
if (result.affectedRows > 0) return { success: true };
}
if (!currentState[0][0].in_safe) {
const [result] = await pool.query(
"UPDATE items SET in_safe = true WHERE id = ?",
[itemId]
);
if (result.affectedRows > 0) return { success: true };
}
return { success: false };
};

View File

@@ -0,0 +1,23 @@
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 getAllLoans = async () => {
const [rows] = await pool.query("SELECT * FROM loans");
return { success: true, data: rows };
};
export const deleteLoanById = async (loanId) => {
const [result] = await pool.query("DELETE FROM loans WHERE id = ?", [loanId]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};

View File

@@ -0,0 +1,79 @@
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 createUser = async (
username,
role,
password,
isAdmin,
email,
first_name,
last_name
) => {
const [result] = await pool.query(
"INSERT INTO users (username, role, password, is_admin, email, first_name, last_name) VALUES (?, ?, ?, ?, ?, ?, ?)",
[username, role, password, isAdmin, email, first_name, last_name]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const deleteUserById = async (userId) => {
const [result] = await pool.query("DELETE FROM users WHERE id = ?", [userId]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const changePassword = async (userId, newPassword) => {
const [result] = await pool.query(
"UPDATE users SET password = ?, entry_updated_at = NOW() WHERE id = ?",
[newPassword, userId]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const editUserById = async (
userId,
first_name,
last_name,
role,
email,
is_admin
) => {
const [result] = await pool.query(
"UPDATE users SET first_name = ?, last_name = ?, role = ?, email = ?, is_admin = ?, entry_updated_at = NOW() WHERE id = ?",
[first_name, last_name, role, email, is_admin, userId]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const getAllUsers = async () => {
const [result] = await pool.query(
"SELECT id, username, first_name, last_name, role, email, is_admin, entry_created_at, entry_updated_at FROM users"
);
if (result.length > 0) return { success: true, data: result };
return { success: false };
};
export const getUserById = async (userId) => {
const [rows] = await pool.query(
"SELECT id, username, first_name, last_name, role, email, is_admin FROM users WHERE id = ?",
[userId]
);
if (rows.length === 0) {
return { success: false };
}
return { success: true, data: rows[0] };
};

View File

@@ -0,0 +1,47 @@
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 loginAdmin = async (username, password) => {
const [rows] = await pool.query(
"SELECT id, username, first_name, last_name, role, is_admin FROM users WHERE username = ? AND password = ?",
[username, password]
);
if (rows.length === 0) {
return { success: false, reason: "invalid_credentials" };
}
const user = rows[0];
if (!user.is_admin) {
return { success: false, reason: "not_admin" };
}
return { success: true, data: user };
};
export const executeQuery = async (query, password, username) => {
let verified = false;
const [user] = await pool.query(
"SELECT * FROM users WHERE username = ? AND password = ?",
[username, password]
);
if (user.length > 0 && user[0].is_admin) {
verified = true;
}
if (!verified) {
return { success: false, message: "Unauthorized" };
}
const [result] = await pool.query(`${query}`);
return { success: true, data: result };
};

View File

@@ -0,0 +1,68 @@
import express from "express";
import { authenticateAdmin } from "../../services/authentication.js";
const router = express.Router();
import dotenv from "dotenv";
dotenv.config();
// database funcs import
import {
editItemById,
getAllItems,
deleteItemById,
createItem,
changeSafeState,
} from "./database/itemDataMgmt.database.js";
router.get("/all-items", authenticateAdmin, async (req, res) => {
const result = await getAllItems();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to retrieve items" });
});
router.delete("/delete-item/:id", authenticateAdmin, async (req, res) => {
const itemId = req.params.id;
const result = await deleteItemById(itemId);
if (result.success) {
return res.status(200).json({ message: "Item deleted successfully" });
}
return res.status(500).json({ message: "Failed to delete item" });
});
router.post("/create-item", authenticateAdmin, async (req, res) => {
const { item_name, can_borrow_role, lockerNumber } = req.body;
const result = await createItem(item_name, can_borrow_role, lockerNumber);
if (result.success) {
return res.status(201).json({ message: "Item created successfully" });
}
return res.status(500).json({ message: "Failed to create item" });
});
router.post("/edit-item/:id", authenticateAdmin, async (req, res) => {
const itemId = req.params.id;
const { item_name, can_borrow_role, safe_nr, door_key } = req.body;
const result = await editItemById(
itemId,
item_name,
can_borrow_role,
safe_nr,
door_key
);
if (result.success) {
return res.status(200).json({ message: "Item edited successfully" });
}
return res.status(500).json({ message: "Failed to edit item" });
});
router.post("/change-safe-state/:id", authenticateAdmin, async (req, res) => {
const itemId = req.params.id;
const result = await changeSafeState(itemId);
if (result.success) {
return res.status(200).json({ message: "Safe state changed successfully" });
}
return res.status(500).json({ message: "Failed to change safe state" });
});
export default router;

View File

@@ -0,0 +1,30 @@
import express from "express";
import { authenticateAdmin } from "../../services/authentication.js";
const router = express.Router();
import dotenv from "dotenv";
dotenv.config();
// database funcs import
import {
deleteLoanById,
getAllLoans,
} from "./database/loanDataMgmt.database.js";
router.get("/all-loans", authenticateAdmin, async (req, res) => {
const result = await getAllLoans();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to retrieve loans" });
});
router.delete("/delete-loan/:id", authenticateAdmin, async (req, res) => {
const loanId = req.params.id;
const result = await deleteLoanById(loanId);
if (result.success) {
return res.status(200).json({ message: "Loan deleted successfully" });
}
return res.status(500).json({ message: "Failed to delete loan" });
});
export default router;

View File

@@ -0,0 +1,123 @@
import express from "express";
import { authenticateAdmin } from "../../services/authentication.js";
const router = express.Router();
import dotenv from "dotenv";
dotenv.config();
// database funcs import
import {
createUser,
deleteUserById,
editUserById,
changePassword,
getAllUsers,
getUserById,
} from "./database/userDataMgmt.database.js";
router.post("/create-user", authenticateAdmin, async (req, res) => {
const username = req.body.username;
const role = req.body.role;
const password = req.body.password;
const isAdmin = req.body.isAdmin;
const email = req.body.email;
const first_name = req.body.first_name;
const last_name = req.body.last_name;
const result = await createUser(
username,
role,
password,
isAdmin,
email,
first_name,
last_name
);
if (result.success) {
return res.status(201).json({ message: "User created successfully" });
}
return res.status(500).json({ message: "Failed to create user" });
});
router.delete("/delete-user/:id", authenticateAdmin, async (req, res) => {
const userId = req.params.id;
const result = await deleteUserById(userId);
if (result.success) {
return res.status(200).json({ message: "User deleted successfully" });
}
return res.status(500).json({ message: "Failed to delete user" });
});
router.post("/edit-user/:id", authenticateAdmin, async (req, res) => {
const first_name = req.body.first_name;
const last_name = req.body.last_name;
const role = req.body.role;
const email = req.body.email;
const userId = req.params.id;
const is_admin = req.body.is_admin;
const result = await editUserById(
userId,
first_name,
last_name,
role,
email,
is_admin
);
if (result.success) {
return res.status(200).json({ message: "User edited successfully" });
}
return res.status(500).json({ message: "Failed to edit user" });
});
router.post("/change-password", authenticateAdmin, async (req, res) => {
const username = req.body.username;
const password = req.body.password;
const result = await changePassword(username, password);
if (result.success) {
return res.status(200).json({ message: "Password reset successfully" });
}
return res.status(500).json({ message: "Failed to reset password" });
});
router.post("/edit-user/:id", authenticateAdmin, async (req, res) => {
const userId = req.params.id;
const first_name = req.body.first_name;
const last_name = req.body.last_name;
const role = req.body.role;
const email = req.body.email;
const is_admin = req.body.is_admin;
const result = await editUserById(
userId,
first_name,
last_name,
role,
email,
is_admin
);
if (result.success) {
return res.status(200).json({ message: "User edited successfully" });
}
return res.status(500).json({ message: "Failed to edit user" });
});
router.get("/users", authenticateAdmin, async (req, res) => {
const result = await getAllUsers();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to retrieve users" });
});
router.get("/user/:id", authenticateAdmin, async (req, res) => {
const result = await getUserById(req.params.id);
if (result.success) {
return res.status(200).json({ user: result.data });
}
return res.status(500).json({ message: "Failed to retrieve user" });
});
export default router;

View File

@@ -0,0 +1,54 @@
import express from "express";
import {
generateToken,
authenticateAdmin,
} from "../../services/authentication.js";
const router = express.Router();
import dotenv from "dotenv";
dotenv.config();
// database funcs import
import { loginAdmin, executeQuery } from "./database/userMgmt.database.js";
router.post("/login", async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
return res.status(400).json({ message: "Missing username or password" });
}
const result = await loginAdmin(username, password);
if (result.success) {
const token = await generateToken({
username: result.data.username,
first_name: result.data.first_name,
last_name: result.data.last_name,
admin: result.data.is_admin,
});
return res.status(200).json({
message: "Login erfolgreich",
token,
first_name: result.data.first_name,
});
}
if (result.reason === "not_admin") {
return res.status(403).json({ message: "Du bist kein Admin" });
}
return res.status(401).json({ message: "Ungültige Anmeldedaten" });
});
router.get("/verify-token", authenticateAdmin, async (req, res) => {
return res.status(200).json({ message: "Token is valid" });
});
router.post("/database-query", authenticateAdmin, async (req, res) => {
const query = req.body.query;
const password = req.body.password;
const username = req.body.username;
const result = await executeQuery(query, password, username);
});
export default router;

View File

@@ -0,0 +1,135 @@
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 getItemsFromDatabaseV2 = async () => {
const [rows] = await pool.query("SELECT * FROM items;");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
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]
);
if (result.length > 0) {
return { success: true, data: result[0] };
}
return { success: false };
};
export const changeInSafeStateV2 = async (itemId) => {
const [result] = await pool.query(
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
[itemId]
);
if (result.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const setReturnDateV2 = async (loanCode) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode]
);
const [owner] = await pool.query(
"SELECT username FROM loans WHERE loan_code = ?",
[loanCode]
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET in_safe = 1, currently_borrowing = NULL, last_borrowed_person = (?) WHERE id IN (?)",
[owner[0].username, itemIds]
);
const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
[loanCode]
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const setTakeDateV2 = async (loanCode) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode]
);
const [owner] = await pool.query(
"SELECT username FROM loans WHERE loan_code = ?",
[loanCode]
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET in_safe = 0, currently_borrowing = (?) WHERE id IN (?)",
[owner[0].username, itemIds]
);
const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
[loanCode]
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const getAllLoansV2 = async () => {
const [result] = await pool.query("SELECT * FROM loans;");
if (result.length > 0) {
return { success: true, data: result };
}
return { success: false };
};
export const openDoor = async (doorKey) => {
const [result] = await pool.query(
"SELECT safe_nr, id FROM items WHERE door_key = ?;",
[doorKey]
);
if (result.length > 0) {
const [changeItemSate] = await pool.query(
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
[result[0].id]
);
if (changeItemSate.affectedRows > 0) {
return { success: true, data: result[0] };
} else {
return { success: false };
}
}
return { success: false };
};

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