62 Commits

Author SHA1 Message Date
893a7e041d Merge branch 'dev_v1-admin' into debian12_v1-admin 2025-10-04 00:29:30 +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
d4b2e8db20 Merge branch 'dev_v1-admin' into debian12_v1-admin 2025-10-02 22:33:18 +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
b52d707bf5 Merge branch 'dev_v1-admin' into debian12_v1-admin 2025-09-29 10:45:23 +02:00
32abe60d98 fixed api route for setting the return and take date 2025-09-29 10:44:51 +02:00
b6ebfcd631 Merge branch 'dev_v1-admin' into debian12_v1-admin 2025-09-28 16:08:20 +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
7ecd9dad3f Merge branch 'dev_v1-admin' into debian12_v1-admin 2025-09-27 23:29:54 +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
7f9ed23a86 changed links 2025-09-27 22:56:17 +02:00
21b152ef2b Merge branch 'dev_v1-admin' into debian12_v1-admin 2025-09-27 22:55:22 +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
451a5a92dd update API endpoints in Landingpage component to use production URLs 2025-09-24 17:57:44 +02:00
85b519c5b1 Merge branch 'dev_v1-admin' into debian12_v1-admin 2025-09-24 17:56:52 +02:00
49f4ba8483 added itemview to landingpage 2025-09-22 13:22:27 +02:00
27db4c7390 changed ports 2025-09-21 10:46:53 +02:00
1b08344a0f Merge branch 'dev_v1-admin' into debian12_v1-admin 2025-09-21 10:46:07 +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
a0be100db5 update changeSafeState function to use PUT method for state changes 2025-09-16 13:40:23 +02:00
2143d53eb5 chaged ports accordingly 2025-09-16 13:04:13 +02:00
c4d5ebd9ae Merge branch 'dev_v1-admin' into debian12_v1-admin 2025-09-16 13:03:35 +02:00
938e9000f8 chnaged port config 2025-09-16 11:26:31 +02:00
558a8330af Merge branch 'dev_v1-admin' into debian12_v1-admin 2025-09-16 11:20:37 +02:00
ad2395f98b Merge branch 'dev_v1-admin' into debian12_v1-admin 2025-09-05 11:27:39 +02:00
51baf8d970 Merge branch 'dev_v1-admin' into debian12_v1-admin 2025-09-03 15:25:05 +02:00
d18465ff1d changed urls 2025-09-03 14:54:20 +02:00
b36f1ba9ba Merge branch 'dev_v1-admin' into debian12_v1-admin 2025-09-03 14:53:25 +02:00
784bd1e8ce Merge branch 'dev_v1-admin' into debian12_v1-admin 2025-09-02 20:55:55 +02:00
ae7aec8d3b fix: add missing network configuration for admin-frontend service 2025-09-02 20:45:02 +02:00
3f9381a80c changed docker and ports 2025-09-02 20:37:32 +02:00
1826086186 changed docker config 2025-09-02 20:35:42 +02:00
af4abfc8f9 changed links for hosting 2025-09-02 20:34:20 +02:00
ba0f06e104 Merge branch 'dev_v1-admin' into debian12_v1-admin 2025-09-02 20:31:28 +02:00
a932144e94 Update README.md 2025-08-21 19:02:13 +02:00
36ad60b782 Merge branch 'dev' into debian12 2025-08-21 18:55:50 +02:00
e4467dba32 Merge branch 'dev' into debian12 2025-08-20 18:10:27 +02:00
410923af92 Merge branch 'dev' into debian12 2025-08-20 13:39:19 +02:00
24c405386b fixed url bug 2025-08-20 13:21:55 +02:00
d5296bd3fa Merge branch 'dev' into debian12 2025-08-20 13:18:01 +02:00
3ee2f6b670 Merge branch 'dev' into debian12 2025-08-20 01:08:15 +02:00
09af4c760c fix: update database connection settings in docker-compose and database service 2025-08-20 00:49:44 +02:00
3fd0fd9584 fix: add borrow_system-internal network to frontend, backend, and mysql services in docker-compose 2025-08-20 00:45:43 +02:00
27984ebac8 added 2025-08-20 00:42:56 +02:00
3d4aab74d5 changed back 2025-08-20 00:30:49 +02:00
4076630eec changed 2025-08-20 00:27:06 +02:00
6025212e93 refactor: remove redundant environment variable validation and connection check in database service
fix: update dependency reference for backend service in docker-compose
2025-08-20 00:25:35 +02:00
de554048eb added tester 2025-08-20 00:20:35 +02:00
e1d79d2c79 fix: update port numbers and API endpoints for consistency across backend and frontend 2025-08-19 23:55:13 +02:00
32 changed files with 1338 additions and 667 deletions

View File

@@ -1,4 +1,4 @@
# Backend API docs # Backend API docs (apiV2)
If you want to cooperate with me, or build something new with my backend API, feel free to reach out! If you want to cooperate with me, or build something new with my backend API, feel free to reach out!
@@ -6,49 +6,51 @@ On this page you will learn how my API works.
## General information ## 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. 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` which 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`.** 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 an API Key. You can get the API key from my admin dashboard. When you don't have any access to my admin dashboard, please contact your administrator or me.
But first you have to get the Admin API key, stored in an `.env` file on my server. ---
## Base URL
- Frontend: `https://insta.the1s.de`
- Backend: `https://backend.insta.the1s.de`
- Base path for this API: `https://backend.insta.the1s.de/apiV2`
You can see the status of this and all my other services at `https://status.the1s.de`.
_I have also build a [fallback page](https://git.the1s.de/theis.gaedigk/fallback-page). When only the application is down, you will see a friendly message and a link to the status page. (Only if the server is not down)_
--- ---
## Authentication ## Authentication
All endpoints require the Admin API key (`ADMIN_ID`) as a URL parameter. All endpoints require an API key as a path parameter named `:key`.
Example: `/apiV2/items/{ADMIN_ID}` Example: `/apiV2/items/:key`
If the key is missing or invalid, the API responds with `401 Unauthorized`.
--- ---
## URL ## Endpoints
- The frontend is currently running on `https://insta.the1s.de`. ### 1) Get all items
- The backend is currently running on `https://backend.insta.the1s.de`. GET `/apiV2/items/:key`
You can see the status of this and all my other services at `https://status.the1s.de`. Returns a list of all items wrapped in a `data` object.
--- Example request:
## Current endpoints
### 1. Get All Items
**GET** `/apiV2/items/:key`
Returns a list of all items and their details.
#### Example Request
``` ```
GET https://backend.insta.the1s.de/apiV2/items/your_admin_key GET https://backend.insta.the1s.de/apiV2/items/12345
``` ```
#### Example Response Example response:
``` ```
{ {
@@ -59,206 +61,66 @@ GET https://backend.insta.the1s.de/apiV2/items/your_admin_key
"can_borrow_role": 4, "can_borrow_role": 4,
"inSafe": 1, "inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z" "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"
} }
] ]
} }
``` ```
Each item has the following properties: Fields:
- `id`: The unique identifier for the item. - `id`: Unique identifier
- `item_name`: The name of the item. - `item_name`: Item name
- `can_borrow_role`: The role ID that is allowed to borrow the item. - `can_borrow_role`: Role allowed to borrow
- `inSafe`: Indicates whether the item is currently in the locker (1) or not (0). This variable/state can change over time. - `inSafe`: 1 if in locker, 0 otherwise
- `entry_created_at`: Creation timestamp
_You also get an http 200 status code._ Status: 200 on success, 500 on failure.
--- ---
### 2. Change Item Safe State ### 2) Change item safe state
**POST** `/apiV2/controlInSafe/:key/:itemId/:state` POST `/apiV2/controlInSafe/:key/:itemId/:state`
Updates the `inSafe` state of an item (whether it is in the locker). Updates `inSafe` (locker) state of an item.
- `state` must be `"1"` (in safe) or `"0"` (not in safe). - `state` must be `"1"` (in safe) or `"0"` (not in safe)
#### Example Request Example request:
``` ```
POST https://backend.insta.the1s.de/apiV2/controlInSafe/your_admin_key/item_id/new_item_state POST https://backend.insta.the1s.de/apiV2/controlInSafe/12345/123/1
``` ```
#### Example Response Example response (shape depends on database service):
``` ```
{} { "data": { /* update result */ } }
``` ```
_An empty object means, that the operation was successful and no further information is returned._ Status:
_You also get an http 200 status code._ - 200 on success
- 400 if `state` is invalid
- 500 on failure
**You can get the item id on the admin panel, from your system administrator.**
--- ---
### 3. Set Return Date ### 3) Get loan by code
**POST** `/apiV2/setReturnDate/:key/:loan_code` GET `/apiV2/getLoanByCode/:key/:loan_code`
Sets the `returned_date` of a loan to the current server time. Retrieves the details of a specific loan.
- `loan_code`: The unique code of the loan. Example request:
#### Example Request
``` ```
POST https://backend.insta.the1s.de/apiV2/setReturnDate/your_admin_key/your_loan_code GET https://backend.insta.the1s.de/apiV2/getLoanByCode/12345/123456
``` ```
#### Example Response Example response:
```
{}
```
_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
``` ```
{ {
@@ -271,36 +133,78 @@ GET https://backend.insta.the1s.de/getLoanByCode/your_admin_key/your_loan_code
"take_date": null, "take_date": null,
"returned_date": null, "returned_date": null,
"created_at": "2025-08-20T11:23:40.000Z", "created_at": "2025-08-20T11:23:40.000Z",
"loaned_items_id": [ "loaned_items_id": [8, 9],
8, "loaned_items_name": ["SD Karten", "Kameragimbal"]
9
],
"loaned_items_name": [
"SD Karten",
"Kameragimbal"
]
} }
} }
``` ```
_You also get an http 200 status code._ Status:
If the loan id does not exist, you will receive a 404 status code and an error message. - 200 on success
- 404 if not found
```
{
"message": "Loan not found"
}
```
--- ---
## Error Handling ### 4) Set return date (now) by loan code
- `403 Forbidden`: Invalid or missing API key. POST `/apiV2/setReturnDate/:key/:loan_code`
- `400 Bad Request`: Invalid parameters (e.g., wrong state value).
- `500 Internal Server Error`: Database or server error. Sets the `returned_date` to the current server time.
**Note:** I have updated this API route, so that everytime you return or take a loan, the state of the loaned items is automatically updated.
**DO NOT UPDATE THE STATE MANUALLY! (only if the item was taken with an admin key)**
Example request:
```
POST https://backend.insta.the1s.de/apiV2/setReturnDate/12345/123456
```
Example response:
```
{ "data": { /* update result */ } }
```
Status: 200 on success, 500 on failure.
--- ---
If you have questions or want to collaborate, please reach out to me! ### 5) Set take date (now) by loan code
POST `/apiV2/setTakeDate/:key/:loan_code`
Sets the `take_date` to the current server time.
**Note:** I have updated this API route, so that everytime you return or take a loan, the state of the loaned items is automatically updated.
**DO NOT UPDATE THE STATE MANUALLY! (only if the item was taken with an admin key)**
Example request:
```
POST https://backend.insta.the1s.de/apiV2/setTakeDate/12345/123456
```
Example response:
```
{ "data": { /* update result */ } }
```
Status: 200 on success, 500 on failure.
---
## Error handling
- 401 Unauthorized: Missing or invalid API key
- 400 Bad Request: Invalid parameters (e.g., wrong state value)
- 404 Not Found: Loan not found
- 500 Internal Server Error: Database or server error
---
If you have questions or want to collaborate, please reach out!

View File

@@ -1,73 +1,7 @@
# Borrow System # Borrow System
![React](https://img.shields.io/badge/React-20232A?logo=react&logoColor=61DAFB) **You have reached the `debian12` branch.**
![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)
A small fullstack system to log in, view available items, reserve them for a time window, and manage personal loans. Here you will find the source code of exactly the application that I have hosted.
- Frontend: React + TypeScript + Vite + Tailwind CSS The main branch or the branch that I am developing on, is the `dev` branch.
- 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,10 +1,12 @@
import React from "react"; import React from "react";
import { useState } from "react"; import { useState } from "react";
import { useEffect } from "react";
import { Box, Heading, Text, Flex, Button } from "@chakra-ui/react"; import { Box, Heading, Text, Flex, Button } from "@chakra-ui/react";
import Sidebar from "./Sidebar"; import Sidebar from "./Sidebar";
import UserTable from "../components/UserTable"; import UserTable from "../components/UserTable";
import ItemTable from "../components/ItemTable"; import ItemTable from "../components/ItemTable";
import LoanTable from "../components/LoanTable"; import LoanTable from "../components/LoanTable";
import APIKeyTable from "@/components/APIKeyTable";
import { MoveLeft } from "lucide-react"; import { MoveLeft } from "lucide-react";
type DashboardProps = { type DashboardProps = {
@@ -16,6 +18,24 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
const [activeView, setActiveView] = useState(""); 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 ( return (
<Flex h="100vh"> <Flex h="100vh">
<Sidebar <Sidebar
@@ -23,6 +43,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
viewGegenstaende={() => setActiveView("Gegenstände")} viewGegenstaende={() => setActiveView("Gegenstände")}
viewSchliessfaecher={() => setActiveView("Schließfächer")} viewSchliessfaecher={() => setActiveView("Schließfächer")}
viewUser={() => setActiveView("User")} viewUser={() => setActiveView("User")}
viewAPI={() => setActiveView("API")}
/> />
<Box flex="1" display="flex" flexDirection="column"> <Box flex="1" display="flex" flexDirection="column">
<Flex <Flex
@@ -66,6 +87,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
{activeView === "User" && <UserTable />} {activeView === "User" && <UserTable />}
{activeView === "Ausleihen" && <LoanTable />} {activeView === "Ausleihen" && <LoanTable />}
{activeView === "Gegenstände" && <ItemTable />} {activeView === "Gegenstände" && <ItemTable />}
{activeView === "API" && <APIKeyTable />}
</Box> </Box>
</Box> </Box>
</Flex> </Flex>

View File

@@ -5,6 +5,11 @@ import Login from "./Login";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import Landingpage from "@/components/API/Landingpage"; import Landingpage from "@/components/API/Landingpage";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
const Layout: React.FC = () => { const Layout: React.FC = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
const [showAPI, setShowAPI] = useState(false); const [showAPI, setShowAPI] = useState(false);
@@ -19,7 +24,7 @@ const Layout: React.FC = () => {
if (Cookies.get("token")) { if (Cookies.get("token")) {
const verifyToken = async () => { const verifyToken = async () => {
const response = await fetch("http://localhost:8002/api/verifyToken", { const response = await fetch(`${API_BASE}/api/verifyToken`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,
@@ -39,6 +44,7 @@ const Layout: React.FC = () => {
const handleLogout = () => { const handleLogout = () => {
Cookies.remove("token"); Cookies.remove("token");
window.location.pathname = "/";
setIsLoggedIn(false); setIsLoggedIn(false);
}; };

View File

@@ -24,41 +24,43 @@ const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
return ( return (
<div className="min-h-screen flex items-center justify-center p-4"> <div className="min-h-screen flex items-center justify-center p-4">
<Card.Root maxW="sm"> <form onSubmit={(e) => e.preventDefault()}>
<Card.Header> <Card.Root maxW="sm">
<Card.Title>Login</Card.Title> <Card.Header>
<Card.Description> <Card.Title>Login</Card.Title>
Bitte unten Ihre Admin Zugangsdaten eingeben. <Card.Description>
</Card.Description> Bitte unten Ihre Admin Zugangsdaten eingeben.
</Card.Header> </Card.Description>
<Card.Body> </Card.Header>
<Stack gap="4" w="full"> <Card.Body>
<Field.Root> <Stack gap="4" w="full">
<Field.Label>username</Field.Label> <Field.Root>
<Input <Field.Label>username</Field.Label>
value={username} <Input
onChange={(e) => setUsername(e.target.value)} value={username}
/> onChange={(e) => setUsername(e.target.value)}
</Field.Root> />
<Field.Root> </Field.Root>
<Field.Label>password</Field.Label> <Field.Root>
<Input <Field.Label>password</Field.Label>
type="password" <Input
value={password} type="password"
onChange={(e) => setPassword(e.target.value)} value={password}
/> onChange={(e) => setPassword(e.target.value)}
</Field.Root> />
</Stack> </Field.Root>
</Card.Body> </Stack>
<Card.Footer justifyContent="flex-end"> </Card.Body>
{isError && ( <Card.Footer justifyContent="flex-end">
<MyAlert status="error" title={errorMsg} description={errorDsc} /> {isError && (
)} <MyAlert status="error" title={errorMsg} description={errorDsc} />
<Button onClick={() => handleLogin()} variant="solid"> )}
Login <Button type="submit" onClick={() => handleLogin()} variant="solid">
</Button> Login
</Card.Footer> </Button>
</Card.Root> </Card.Footer>
</Card.Root>
</form>
</div> </div>
); );
}; };

View File

@@ -6,12 +6,14 @@ type SidebarProps = {
viewGegenstaende: () => void; viewGegenstaende: () => void;
viewSchliessfaecher: () => void; viewSchliessfaecher: () => void;
viewUser: () => void; viewUser: () => void;
viewAPI: () => void;
}; };
const Sidebar: React.FC<SidebarProps> = ({ const Sidebar: React.FC<SidebarProps> = ({
viewAusleihen, viewAusleihen,
viewGegenstaende, viewGegenstaende,
viewUser, viewUser,
viewAPI,
}) => { }) => {
return ( return (
<Box <Box
@@ -58,6 +60,15 @@ const Sidebar: React.FC<SidebarProps> = ({
> >
Gegenstände Gegenstände
</Link> </Link>
<Link
px={3}
py={2}
rounded="md"
_hover={{ bg: "gray.700", textDecoration: "none" }}
onClick={viewAPI}
>
API Keys
</Link>
</VStack> </VStack>
<Box mt="auto" pt={8} fontSize="xs" color="gray.500"> <Box mt="auto" pt={8} fontSize="xs" color="gray.500">

View File

@@ -1,211 +1,236 @@
import React from "react"; import React, { useEffect, useState } from "react";
import { useEffect } from "react"; import {
import { useState } from "react"; Spinner,
import { Spinner, Text, VStack, Box } from "@chakra-ui/react"; Text,
import { Table, Heading } from "@chakra-ui/react"; VStack,
Table,
Heading,
HStack,
Card,
SimpleGrid,
Button,
} from "@chakra-ui/react";
import { Lock, LockOpen } from "lucide-react";
import MyAlert from "../myChakra/MyAlert";
import { formatDateTime } from "@/utils/userFuncs"; import { formatDateTime } from "@/utils/userFuncs";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
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;
inSafe: number;
entry_created_at: string;
};
const Landingpage: React.FC = () => { const Landingpage: React.FC = () => {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [loans, setLoans] = useState<any[]>([]); 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(() => { useEffect(() => {
setIsLoading(true); const fetchData = async () => {
fetch("http://localhost:8002/apiV2/allLoans") setIsLoading(true);
.then((response) => response.json()) try {
.then((data) => { const loanRes = await fetch(`${API_BASE}/apiV2/allLoans`);
setLoans(data); const loanData = await loanRes.json();
if (Array.isArray(loanData)) {
setLoans(loanData);
} else {
setError(
"error",
"Fehler beim Laden",
"Unerwartetes Datenformat erhalten. (Ausleihen)"
);
}
const deviceRes = await fetch(`${API_BASE}/apiV2/allItems`);
const deviceData = await deviceRes.json();
if (Array.isArray(deviceData)) {
setDevices(deviceData);
} else {
setError(
"error",
"Fehler beim Laden",
"Unerwartetes Datenformat erhalten. (Geräte)"
);
}
} catch (e) {
setError(
"error",
"Fehler beim Laden",
"Die Ausleihen konnten nicht geladen werden."
);
} finally {
setIsLoading(false); setIsLoading(false);
}) }
.catch((error) => { };
console.error("Error fetching loans:", error); fetchData();
setIsLoading(false);
});
}, []); }, []);
return ( return (
<> <>
<Heading as="h1" size="lg" mb={4}> <Heading as="h1" size="lg" mb={2}>
Matthias-Claudius-Schule Technik Matthias-Claudius-Schule Technik
</Heading> </Heading>
<Heading as="h2" size="md" mb={4}> <Heading as="h2" size="md" mb={4}>
Alle Ausleihen Alle Ausleihen
</Heading> </Heading>
{isError && (
<MyAlert
status={errorStatus}
description={errorDsc}
title={errorMessage}
/>
)}
{isLoading && ( {isLoading && (
<VStack colorPalette="teal"> <VStack colorPalette="teal">
<Spinner color="colorPalette.600" /> <Spinner color="colorPalette.600" />
<Text color="colorPalette.600">Loading...</Text> <Text color="colorPalette.600">Loading...</Text>
</VStack> </VStack>
)} )}
{!isLoading && ( {!isLoading && (
<Box <Table.Root size="sm" striped>
borderWidth="1px" <Table.Header>
borderRadius="lg" <Table.Row>
overflow="hidden" <Table.ColumnHeader>
boxShadow="sm" <strong>#</strong>
bg="white" </Table.ColumnHeader>
_dark={{ bg: "gray.800", borderColor: "gray.700" }} <Table.ColumnHeader>
> <strong>Benutzername</strong>
<Box maxH="70vh" overflow="auto"> </Table.ColumnHeader>
<Table.Root <Table.ColumnHeader>
size="md" <strong>Startdatum</strong>
variant="outline" </Table.ColumnHeader>
colorPalette="teal" <Table.ColumnHeader>
w="full" <strong>Enddatum</strong>
minW="900px" </Table.ColumnHeader>
> <Table.ColumnHeader>
<Table.ColumnGroup> <strong>Ausgeliehene Artikel</strong>
<Table.Column htmlWidth="50%" /> </Table.ColumnHeader>
<Table.Column htmlWidth="40%" /> <Table.ColumnHeader>
<Table.Column /> <strong>Rückgabedatum</strong>
</Table.ColumnGroup> </Table.ColumnHeader>
<Table.Header <Table.ColumnHeader>
position="sticky" <strong>Ausleihdatum</strong>
top={0} </Table.ColumnHeader>
zIndex={1} </Table.Row>
bg="gray.50" </Table.Header>
_dark={{ bg: "gray.700" }} <Table.Body>
> {loans.map((loan) => (
<Table.Row> <Table.Row key={loan.id}>
<Table.ColumnHeader <Table.Cell>{loan.id}</Table.Cell>
textTransform="uppercase" <Table.Cell>{loan.username}</Table.Cell>
letterSpacing="wider" <Table.Cell>{formatDateTime(loan.start_date)}</Table.Cell>
fontSize="xs" <Table.Cell>{formatDateTime(loan.end_date)}</Table.Cell>
color="gray.600" <Table.Cell>
_dark={{ color: "gray.200" }} {Array.isArray(loan.loaned_items_name)
py={3} ? loan.loaned_items_name.join(", ")
> : loan.loaned_items_name}
<strong>#</strong> </Table.Cell>
</Table.ColumnHeader> <Table.Cell>{formatDateTime(loan.returned_date)}</Table.Cell>
<Table.ColumnHeader <Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell>
textTransform="uppercase" </Table.Row>
letterSpacing="wider" ))}
fontSize="xs" </Table.Body>
color="gray.600" </Table.Root>
_dark={{ color: "gray.200" }}
py={3}
>
<strong>Username</strong>
</Table.ColumnHeader>
<Table.ColumnHeader
textTransform="uppercase"
letterSpacing="wider"
fontSize="xs"
color="gray.600"
_dark={{ color: "gray.200" }}
py={3}
>
<strong>Start Date</strong>
</Table.ColumnHeader>
<Table.ColumnHeader
textTransform="uppercase"
letterSpacing="wider"
fontSize="xs"
color="gray.600"
_dark={{ color: "gray.200" }}
py={3}
>
<strong>End Date</strong>
</Table.ColumnHeader>
<Table.ColumnHeader
textTransform="uppercase"
letterSpacing="wider"
fontSize="xs"
color="gray.600"
_dark={{ color: "gray.200" }}
py={3}
>
<strong>Loaned Items</strong>
</Table.ColumnHeader>
<Table.ColumnHeader
textTransform="uppercase"
letterSpacing="wider"
fontSize="xs"
color="gray.600"
_dark={{ color: "gray.200" }}
py={3}
>
<strong>Returned Date</strong>
</Table.ColumnHeader>
<Table.ColumnHeader
textTransform="uppercase"
letterSpacing="wider"
fontSize="xs"
color="gray.600"
_dark={{ color: "gray.200" }}
py={3}
>
<strong>Take Date</strong>
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{loans.map((loan) => (
<Table.Row
key={loan.id}
_hover={{ bg: "gray.50", _dark: { bg: "whiteAlpha.100" } }}
transition="background-color 120ms ease"
>
<Table.Cell py={3} fontWeight="semibold">
{loan.id}
</Table.Cell>
<Table.Cell py={3}>{loan.username}</Table.Cell>
<Table.Cell
py={3}
whiteSpace="nowrap"
fontFamily="mono"
fontSize="sm"
color="gray.600"
_dark={{ color: "gray.300" }}
>
{formatDateTime(loan.start_date)}
</Table.Cell>
<Table.Cell
py={3}
whiteSpace="nowrap"
fontFamily="mono"
fontSize="sm"
color="gray.600"
_dark={{ color: "gray.300" }}
>
{formatDateTime(loan.end_date)}
</Table.Cell>
<Table.Cell
py={3}
maxW="40ch"
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{loan.loaned_items_name}
</Table.Cell>
<Table.Cell
py={3}
whiteSpace="nowrap"
fontFamily="mono"
fontSize="sm"
color="gray.600"
_dark={{ color: "gray.300" }}
>
{formatDateTime(loan.returned_date)}
</Table.Cell>
<Table.Cell
py={3}
whiteSpace="nowrap"
fontFamily="mono"
fontSize="sm"
color="gray.600"
_dark={{ color: "gray.300" }}
>
{formatDateTime(loan.take_date)}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Box>
</Box>
)} )}
{!isLoading && loans.length === 0 && !isError && (
<Text color="gray.500" mt={2}>
Keine Ausleihen vorhanden.
</Text>
)}
<Heading as="h2" size="md" mb={4}>
Alle Geräte
</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.inSafe ? "green" : "red"}
h="full"
minH="100px"
>
<Card.Header>
{device.inSafe ? <LockOpen size={16} /> : <Lock size={16} />}
<Heading size="md">{device.item_name}</Heading>
</Card.Header>
<Card.Body color="fg.muted">
<Text>Ausleihrolle: {device.can_borrow_role}</Text>
</Card.Body>
</Card.Root>
))}
</SimpleGrid>
<HStack mt={3} gap={3} align="center" role="group" aria-label="Legende">
<Text fontWeight="medium" color="fg.muted">
Legende:
</Text>
<Button
size="sm"
variant="subtle"
colorPalette="green"
pointerEvents="none"
cursor="default"
borderRadius="full"
>
<HStack gap={2}>
<LockOpen size={16} />
<Text>Im Schließfach</Text>
</HStack>
</Button>
<Button
size="sm"
variant="subtle"
colorPalette="red"
pointerEvents="none"
cursor="default"
borderRadius="full"
>
<HStack gap={2}>
<Lock size={16} />
<Text>Nicht im Schließfach</Text>
</HStack>
</Button>
</HStack>
</> </>
); );
}; };

View File

@@ -0,0 +1,208 @@
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";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
type Items = {
id: number;
apiKey: string;
user: string;
entry_created_at: string;
};
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/apiKeys`, {
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
const data = await response.json();
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="md">
Gegenstände
</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>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>API Key</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Benutzer</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((apiKey) => (
<Table.Row key={apiKey.id}>
<Table.Cell>{apiKey.id}</Table.Cell>
<Table.Cell>{apiKey.apiKey}</Table.Cell>
<Table.Cell>{apiKey.user}</Table.Cell>
<Table.Cell>{formatDateTime(apiKey.entry_created_at)}</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
deleteAPKey(apiKey.id).then((response) => {
if (response.success) {
setItems(items.filter((i) => i.id !== apiKey.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,81 @@
import React from "react";
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
import { createAPIentry } from "@/utils/userActions";
type AddAPIKeyProps = {
onClose: () => void;
alert: (
status: "success" | "error",
message: string,
description: string
) => void;
};
const AddAPIKey: React.FC<AddAPIKeyProps> = ({ onClose, alert }) => {
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">
<Field.Root>
<Field.Label>API key</Field.Label>
<Input type="number" id="apiKey" />
</Field.Root>
<Field.Root>
<Field.Label>Benutzer</Field.Label>
<Input id="user" 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 user =
(
document.getElementById("user") as HTMLInputElement
)?.value.trim() || "";
if (!apiKey || !user) return;
const res = await createAPIentry(apiKey, user);
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

@@ -33,7 +33,7 @@ const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
<Input <Input
id="can_borrow_role" id="can_borrow_role"
type="number" type="number"
placeholder="Zahl (z.B. 2)" placeholder="Zahl (1 - 4)"
/> />
</Field.Root> </Field.Root>
</Stack> </Stack>
@@ -68,8 +68,10 @@ const AddItemForm: React.FC<AddItemFormProps> = ({ onClose, alert }) => {
alert( alert(
"error", "error",
"Fehler", "Fehler",
"Der Gegenstand konnte nicht erstellt werden." res.message ||
"Der Gegenstand konnte nicht erstellt werden. (frontend bug)"
); );
onClose();
} }
}} }}
> >

View File

@@ -55,57 +55,64 @@ const ChangePWform: React.FC<ChangePWformProps> = ({
</Field.Root> </Field.Root>
</Stack> </Stack>
</Card.Body> </Card.Body>
<Card.Footer justifyContent="flex-end" gap="2"> <Card.Footer gap="2">
<Button variant="outline" onClick={onClose}> <Stack w="full" gap="3">
Abbrechen <Stack direction="row" justify="flex-end" gap="2">
</Button> <Button variant="outline" onClick={onClose}>
<Button Abbrechen
variant="solid" </Button>
onClick={async () => { <Button
const newPassword = variant="solid"
( onClick={async () => {
document.getElementById("new_password") as HTMLInputElement const newPassword =
)?.value.trim() || ""; (
const confirmNewPassword = document.getElementById(
( "new_password"
document.getElementById( ) as HTMLInputElement
"confirm_new_password" )?.value.trim() || "";
) as HTMLInputElement const confirmNewPassword =
)?.value.trim() || ""; (
document.getElementById(
"confirm_new_password"
) as HTMLInputElement
)?.value.trim() || "";
if (!newPassword || newPassword !== confirmNewPassword) { if (!newPassword || newPassword !== confirmNewPassword) {
subAlert("Passwörter stimmen nicht überein!"); subAlert("Passwörter stimmen nicht überein!");
return; return;
} }
const res = await changePW(newPassword, username); const res = await changePW(newPassword, username);
if (res.success) { if (res.success) {
alert( alert(
"success", "success",
"Passwort geändert", "Passwort geändert",
"Das Passwort wurde erfolgreich geändert." "Das Passwort wurde erfolgreich geändert."
); );
onClose(); onClose();
} else { } else {
alert( alert(
"error", "error",
"Fehler", "Fehler",
"Das Passwort konnte nicht geändert werden." "Das Passwort konnte nicht geändert werden."
); );
onClose(); onClose();
} }
}} }}
> >
Ändern Ändern
</Button> </Button>
{showSubAlert && ( </Stack>
<Alert.Root status="error">
<Alert.Indicator /> {showSubAlert && (
<Alert.Content> <Alert.Root status="error">
<Alert.Title>{subAlertMessage}</Alert.Title> <Alert.Indicator />
</Alert.Content> <Alert.Content>
</Alert.Root> <Alert.Title>{subAlertMessage}</Alert.Title>
)} </Alert.Content>
</Alert.Root>
)}
</Stack>
</Card.Footer> </Card.Footer>
</Card.Root> </Card.Root>
</div> </div>

View File

@@ -31,6 +31,11 @@ import {
import AddItemForm from "./AddItemForm"; import AddItemForm from "./AddItemForm";
import { formatDateTime } from "@/utils/userFuncs"; import { formatDateTime } from "@/utils/userFuncs";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
type Items = { type Items = {
id: number; id: number;
item_name: string; item_name: string;
@@ -77,7 +82,7 @@ const ItemTable: React.FC = () => {
const fetchData = async () => { const fetchData = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await fetch("http://localhost:8002/api/allItems", { const response = await fetch(`${API_BASE}/api/allItems`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,

View File

@@ -18,6 +18,11 @@ import { formatDateTime } from "@/utils/userFuncs";
import { Trash2, RefreshCcwDot } from "lucide-react"; import { Trash2, RefreshCcwDot } from "lucide-react";
import { deleteLoan } from "@/utils/userActions"; import { deleteLoan } from "@/utils/userActions";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
const LoanTable: React.FC = () => { const LoanTable: React.FC = () => {
const [items, setItems] = useState<Loan[]>([]); const [items, setItems] = useState<Loan[]>([]);
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error"); const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
@@ -55,7 +60,7 @@ const LoanTable: React.FC = () => {
const fetchData = async () => { const fetchData = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await fetch("http://localhost:8002/api/allLoans", { const response = await fetch(`${API_BASE}/api/allLoans`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,

View File

@@ -1,7 +1,12 @@
import Cookies from "js-cookie"; import Cookies from "js-cookie";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
export const fetchUserData = async () => { export const fetchUserData = async () => {
const response = await fetch("http://localhost:8002/api/allUsers", { const response = await fetch(`${API_BASE}/api/allUsers`, {
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,
}, },

View File

@@ -1,5 +1,10 @@
import Cookies from "js-cookie"; import Cookies from "js-cookie";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
export type LoginSuccess = { success: true }; export type LoginSuccess = { success: true };
export type LoginFailure = { export type LoginFailure = {
success: false; success: false;
@@ -13,7 +18,7 @@ export const loginFunc = async (
password: string password: string
): Promise<LoginResult> => { ): Promise<LoginResult> => {
try { try {
const response = await fetch("http://localhost:8002/api/loginAdmin", { const response = await fetch(`${API_BASE}/api/loginAdmin`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),

View File

@@ -1,9 +1,14 @@
import Cookies from "js-cookie"; import Cookies from "js-cookie";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
export const handleDelete = async (userId: number) => { export const handleDelete = async (userId: number) => {
try { try {
const response = await fetch( const response = await fetch(
`http://localhost:8002/api/deleteUser/${userId}`, `${API_BASE}/api/deleteUser/${userId}`,
{ {
method: "DELETE", method: "DELETE",
headers: { headers: {
@@ -28,7 +33,7 @@ export const handleEdit = async (
) => { ) => {
try { try {
const response = await fetch( const response = await fetch(
`http://localhost:8002/api/editUser/${userId}`, `${API_BASE}/api/editUser/${userId}`,
{ {
method: "POST", method: "POST",
headers: { headers: {
@@ -54,7 +59,7 @@ export const createUser = async (
password: string password: string
) => { ) => {
try { try {
const response = await fetch(`http://localhost:8002/api/createUser`, { const response = await fetch(`${API_BASE}/api/createUser`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -74,7 +79,7 @@ export const createUser = async (
export const changePW = async (newPassword: string, username: string) => { export const changePW = async (newPassword: string, username: string) => {
try { try {
const response = await fetch(`http://localhost:8002/api/changePWadmin`, { const response = await fetch(`${API_BASE}/api/changePWadmin`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -95,7 +100,7 @@ export const changePW = async (newPassword: string, username: string) => {
export const deleteLoan = async (loanId: number) => { export const deleteLoan = async (loanId: number) => {
try { try {
const response = await fetch( const response = await fetch(
`http://localhost:8002/api/deleteLoan/${loanId}`, `${API_BASE}/api/deleteLoan/${loanId}`,
{ {
method: "DELETE", method: "DELETE",
headers: { headers: {
@@ -116,7 +121,7 @@ export const deleteLoan = async (loanId: number) => {
export const deleteItem = async (itemId: number) => { export const deleteItem = async (itemId: number) => {
try { try {
const response = await fetch( const response = await fetch(
`http://localhost:8002/api/deleteItem/${itemId}`, `${API_BASE}/api/deleteItem/${itemId}`,
{ {
method: "DELETE", method: "DELETE",
headers: { headers: {
@@ -139,7 +144,7 @@ export const createItem = async (
can_borrow_role: number can_borrow_role: number
) => { ) => {
try { try {
const response = await fetch(`http://localhost:8002/api/createItem`, { const response = await fetch(`${API_BASE}/api/createItem`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -148,7 +153,11 @@ export const createItem = async (
body: JSON.stringify({ item_name, can_borrow_role }), body: JSON.stringify({ item_name, can_borrow_role }),
}); });
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to create item"); return {
success: false,
message:
"Fehler beim Erstellen des Gegenstands. Der Name des Gegenstandes darf nicht mehrmals vergeben werden.",
};
} }
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
@@ -163,7 +172,7 @@ export const handleEditItems = async (
can_borrow_role: string can_borrow_role: string
) => { ) => {
try { try {
const response = await fetch("http://localhost:8002/api/updateItemByID", { const response = await fetch(`${API_BASE}/api/updateItemByID`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -184,7 +193,7 @@ export const handleEditItems = async (
export const changeSafeState = async (itemId: number) => { export const changeSafeState = async (itemId: number) => {
try { try {
const response = await fetch( const response = await fetch(
`http://localhost:8002/api/changeSafeState/${itemId}`, `${API_BASE}/api/changeSafeState/${itemId}`,
{ {
method: "PUT", method: "PUT",
headers: { headers: {
@@ -201,3 +210,48 @@ export const changeSafeState = async (itemId: number) => {
return { success: false }; return { success: false };
} }
}; };
export const createAPIentry = async (apiKey: string, user: string) => {
try {
const response = await fetch(`${API_BASE}/api/createAPIentry`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
body: JSON.stringify({ apiKey, user }),
});
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/deleteAPKey/${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

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

View File

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

View File

@@ -7,6 +7,6 @@ RUN npm install
COPY . . COPY . .
EXPOSE 8002 EXPOSE 8102
CMD ["npm", "start"] CMD ["npm", "start"]

View File

@@ -14,7 +14,8 @@
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.1.0", "express": "^5.1.0",
"jose": "^6.0.12", "jose": "^6.0.12",
"mysql2": "^3.14.3" "mysql2": "^3.14.3",
"nodemailer": "^7.0.6"
} }
}, },
"node_modules/accepts": { "node_modules/accepts": {
@@ -713,6 +714,15 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/nodemailer": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz",
"integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",

View File

@@ -16,6 +16,7 @@
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.1.0", "express": "^5.1.0",
"jose": "^6.0.12", "jose": "^6.0.12",
"mysql2": "^3.14.3" "mysql2": "^3.14.3",
"nodemailer": "^7.0.6"
} }
} }

View File

@@ -22,9 +22,165 @@ import {
changeUserPasswordFRONTEND, changeUserPasswordFRONTEND,
changeInSafeStateV2, changeInSafeStateV2,
updateItemByID, updateItemByID,
getAllApiKeys,
createAPIentry,
deleteAPKey,
getLoanInfoWithID,
} from "../services/database.js"; } from "../services/database.js";
import { authenticate, generateToken } from "../services/tokenService.js"; import { authenticate, generateToken } from "../services/tokenService.js";
const router = express.Router(); const router = express.Router();
import nodemailer from "nodemailer";
import dotenv from "dotenv";
dotenv.config();
// Nice HTML + text templates for the loan email
function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
const itemsList =
Array.isArray(items) && items.length
? `<ul style="margin:8px 0 0 16px; padding:0;">${items
.map((i) => `<li style="margin:4px 0;">${i}</li>`)
.join("")}</ul>`
: "<span>N/A</span>";
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
</head>
<body style="margin:0; padding:0; background:#f6f9fc; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; color:#111827;">
<div style="padding:24px;">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="max-width:600px; margin:0 auto; background:#ffffff; border:1px solid #e5e7eb; border-radius:12px; overflow:hidden;">
<tr>
<td style="padding:20px 24px; background:${brand}; color:#ffffff;">
<h1 style="margin:0; font-size:18px;">Neue Ausleihe erstellt</h1>
</td>
</tr>
<tr>
<td style="padding:20px 24px;">
<p style="margin:0 0 12px 0;">Es wurde eine neue Ausleihe angelegt. Hier sind die Details:</p>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse;">
<tr>
<td style="padding:8px 0; color:#6b7280; width:180px;">Benutzer</td>
<td style="padding:8px 0; font-weight:600;">${
user || "N/A"
}</td>
</tr>
<tr>
<td style="padding:8px 0; color:#6b7280; vertical-align:top;">Ausgeliehene Gegenstände</td>
<td style="padding:8px 0; font-weight:600;">${itemsList}</td>
</tr>
<tr>
<td style="padding:8px 0; color:#6b7280;">Startdatum</td>
<td style="padding:8px 0; font-weight:600;">${formatDateTime(
startDate
)}</td>
</tr>
<tr>
<td style="padding:8px 0; color:#6b7280;">Enddatum</td>
<td style="padding:8px 0; font-weight:600;">${formatDateTime(
endDate
)}</td>
</tr>
<tr>
<td style="padding:8px 0; color:#6b7280;">Erstellt am</td>
<td style="padding:8px 0; font-weight:600;">${formatDateTime(
createdDate
)}</td>
</tr>
</table>
<p style="margin:20px 0 0 0; font-size:14px;">
<a href="https://admin.insta.the1s.de/api" style="color:${brand}; text-decoration:underline;" target="_blank" rel="noopener noreferrer">
Zur Übersicht aller Ausleihen
</a>
</p>
<p style="margin:16px 0 0 0; font-size:12px; color:#6b7280;">Diese E-Mail wurde automatisch vom Ausleihsystem gesendet. Bitte nicht antworten.</p>
</td>
</tr>
</table>
</div>
</body>
</html>`;
}
function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
const itemsText =
Array.isArray(items) && items.length ? items.join(", ") : "N/A";
return [
"Neue Ausleihe erstellt",
"",
`Benutzer: ${user || "N/A"}`,
`Gegenstände: ${itemsText}`,
`Start: ${formatDateTime(startDate)}`,
`Ende: ${formatDateTime(endDate)}`,
`Erstellt am: ${formatDateTime(createdDate)}`,
].join("\n");
}
function sendMailLoan(user, items, startDate, endDate, createdDate) {
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST,
port: process.env.MAIL_PORT,
secure: true,
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASSWORD,
},
});
(async () => {
const info = await transporter.sendMail({
from: '"Ausleihsystem" <noreply@mcs-medien.de>',
to: process.env.MAIL_SENDEES,
subject: "Eine neue Ausleihe wurde erstellt!",
text: buildLoanEmailText({
user,
items,
startDate,
endDate,
createdDate,
}),
html: buildLoanEmail({ user, items, startDate, endDate, createdDate }),
});
console.log("Message sent:", info.messageId);
})();
console.log("sendMailLoan called");
}
const formatDateTime = (value) => {
if (value == null) return "N/A";
const toOut = (d) => {
if (!(d instanceof Date) || isNaN(d.getTime())) return "N/A";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${dd}.${mm}.${yyyy} ${hh}:${mi} Uhr`;
};
if (value instanceof Date) return toOut(value);
if (typeof value === "number") return toOut(new Date(value));
const s = String(value).trim();
// Direct pattern: "YYYY-MM-DD[ T]HH:mm[:ss]"
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::\d{2})?/);
if (m) {
const [, y, M, d, h, min] = m;
return `${d}.${M}.${y} ${h}:${min} Uhr`;
}
// ISO or other parseable formats
const dObj = new Date(s);
if (!isNaN(dObj.getTime())) return toOut(dObj);
return "N/A";
};
router.post("/login", async (req, res) => { router.post("/login", async (req, res) => {
const result = await loginFunc(req.body.username, req.body.password); const result = await loginFunc(req.body.username, req.body.password);
@@ -155,6 +311,15 @@ router.post("/createLoan", authenticate, async (req, res) => {
); );
if (result.success) { if (result.success) {
const mailInfo = await getLoanInfoWithID(result.data.id);
console.log(mailInfo);
sendMailLoan(
mailInfo.data.username,
mailInfo.data.loaned_items_name,
mailInfo.data.start_date,
mailInfo.data.end_date,
mailInfo.data.created_at
);
return res.status(201).json({ return res.status(201).json({
message: "Loan created successfully", message: "Loan created successfully",
loanId: result.data.id, loanId: result.data.id,
@@ -330,4 +495,66 @@ router.put("/changeSafeState/:itemId", authenticate, async (req, res) => {
return res.status(500).json({ message: "Failed to update item safe state" }); return res.status(500).json({ message: "Failed to update item safe state" });
}); });
router.get("/apiKeys", authenticate, 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 fetch API keys" });
});
router.delete("/deleteAPKey/:id", authenticate, 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" });
});
router.post("/createAPIentry", authenticate, async (req, res) => {
const apiKey = req.body.apiKey;
const user = req.body.user;
if (!apiKey || !user) {
return res.status(400).json({ message: "API key and user are required" });
}
// Ensure apiKey is a number
const apiKeyNum = Number(apiKey);
if (!Number.isFinite(apiKeyNum)) {
return res.status(400).json({ message: "API key must be a number" });
}
const result = await createAPIentry(apiKeyNum, user);
if (result.success) {
return res.status(201).json({ message: "API key created successfully" });
}
if (result.code === "DUPLICATE") {
return res.status(409).json({ message: "API key already exists" });
}
return res.status(500).json({ message: "Failed to create API key" });
});
router.get("/apiKeys/validate/:key", async (req, res) => {
try {
const rawKey = req.params.key;
const result = await getAllApiKeys();
if (!result.success || !Array.isArray(result.data)) {
return res.status(500).json({ valid: false });
}
const isValid = result.data.some((entry) => {
const val = String(
entry?.key ?? entry?.apiKey ?? entry?.api_key ?? entry
);
return val === String(rawKey);
});
return res.status(200).json({ valid: isValid });
} catch (err) {
console.error("validate api key error:", err);
return res.status(500).json({ valid: false });
}
});
export default router; export default router;

View File

@@ -3,34 +3,68 @@ import dotenv from "dotenv";
import { import {
getItemsFromDatabaseV2, getItemsFromDatabaseV2,
changeInSafeStateV2, changeInSafeStateV2,
setReturnDateV2,
setTakeDateV2, setTakeDateV2,
setReturnDateV2,
getLoanByCodeV2, getLoanByCodeV2,
getAllLoansV2, getAllLoansV2,
getAPIkey,
} from "../services/database.js"; } from "../services/database.js";
dotenv.config(); dotenv.config();
const router = express.Router(); const router = express.Router();
// Route for API to get ALL items from the database async function validateAPIKey(apiKey) {
router.get("/items/:key", async (req, res) => { try {
if (req.params.key === process.env.ADMIN_ID) { if (!apiKey) return false;
const result = await getItemsFromDatabaseV2(); const result = await getAPIkey();
if (result.success) { if (!result?.success || !Array.isArray(result.data)) return false;
res.status(200).json({ data: result.data }); return result.data.some((row) => String(row.apiKey) === String(apiKey));
} else { } catch (err) {
res.status(500).json({ message: "Failed to fetch items" }); console.error("validateAPIKey error:", err);
return false;
}
}
// Add a guard that returns Access Denied instead of hanging
const apiKeyGuard = async (req, res, next) => {
try {
const key = req.params.key;
if (!key) {
return res
.status(401)
.json({ message: "Access denied: missing API key" });
} }
const ok = await validateAPIKey(key);
if (!ok) {
return res
.status(401)
.json({ message: "Access denied: invalid API key" });
}
next();
} catch (e) {
console.error("apiKeyGuard error:", e);
res.status(500).json({ message: "Internal server error" });
}
};
// Route for API to get ALL items from the database
router.get("/items/:key", apiKeyGuard, async (req, res) => {
const result = await getItemsFromDatabaseV2();
if (result.success) {
res.status(200).json({ data: result.data });
} else { } else {
res.status(403).json({ message: "Access denied" }); res.status(500).json({ message: "Failed to fetch items" });
} }
}); });
// Route for API to control the position of an item // Route for API to control the position of an item
router.post("/controlInSafe/:key/:itemId/:state", async (req, res) => { router.post(
if (req.params.key === process.env.ADMIN_ID) { "/controlInSafe/:key/:itemId/:state",
apiKeyGuard,
async (req, res) => {
const itemId = req.params.itemId; const itemId = req.params.itemId;
const state = req.params.state; const state = req.params.state;
if (state === "1" || state === "0") { if (state === "1" || state === "0") {
const result = await changeInSafeStateV2(itemId, state); const result = await changeInSafeStateV2(itemId, state);
if (result.success) { if (result.success) {
@@ -41,56 +75,43 @@ router.post("/controlInSafe/:key/:itemId/:state", async (req, res) => {
} else { } else {
res.status(400).json({ message: "Invalid state value" }); res.status(400).json({ message: "Invalid state value" });
} }
}
);
// Route for API to get a loan by its code
router.get("/getLoanByCode/:key/:loan_code", apiKeyGuard, async (req, res) => {
const loan_code = req.params.loan_code;
const result = await getLoanByCodeV2(loan_code);
if (result.success) {
res.status(200).json({ data: result.data });
} else { } else {
res.status(403).json({ message: "Access denied" }); res.status(404).json({ message: "Loan not found" });
} }
}); });
router.get("/getLoanByCode/:key/:loan_code", async (req, res) => { // Route for API to set the return date by the loan code
if (req.params.key === process.env.ADMIN_ID) { router.post("/setReturnDate/:key/:loan_code", apiKeyGuard, async (req, res) => {
const loan_code = req.params.loan_code; const loanCode = req.params.loan_code;
const result = await setReturnDateV2(loanCode);
const result = await getLoanByCodeV2(loan_code); if (result.success) {
if (result.success) { res.status(200).json({ data: result.data });
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 { } else {
res.status(403).json({ message: "Access denied" }); res.status(500).json({ message: "Failed to set return date" });
} }
}); });
// Route for API to set the take away date // Route for API to set the take away date by the loan code
router.post("/setTakeDate/:key/:loan_code", async (req, res) => { router.post("/setTakeDate/:key/:loan_code", apiKeyGuard, async (req, res) => {
if (req.params.key === process.env.ADMIN_ID) { const loanCode = req.params.loan_code;
const loanCode = req.params.loan_code; const result = await setTakeDateV2(loanCode);
if (result.success) {
const result = await setTakeDateV2(loanCode); res.status(200).json({ data: result.data });
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set take date" });
}
} else { } else {
res.status(403).json({ message: "Access denied" }); res.status(500).json({ message: "Failed to set take date" });
} }
}); });
// Route for API to get ALL loans from the database without sensitive info (only for landingpage)
router.get("/allLoans", async (req, res) => { router.get("/allLoans", async (req, res) => {
const result = await getAllLoansV2(); const result = await getAllLoansV2();
if (result.success) { if (result.success) {
@@ -99,4 +120,14 @@ router.get("/allLoans", async (req, res) => {
return res.status(500).json({ message: "Failed to fetch loans" }); return res.status(500).json({ message: "Failed to fetch loans" });
}); });
// Route for API to get ALL items from the database (only for landingpage)
router.get("/allItems", async (req, res) => {
const result = await getItemsFromDatabaseV2();
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch items" });
}
});
export default router; export default router;

View File

@@ -58,6 +58,14 @@ CREATE TABLE `lockers` (
UNIQUE KEY `locker_number` (`locker_number`) UNIQUE KEY `locker_number` (`locker_number`)
); );
CREATE TABLE `apiKeys` (
`id` int NOT NULL AUTO_INCREMENT,
`apiKey` int NOT NULL UNIQUE,
`user` VARCHAR(255) NOT NULL,
`entry_created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
);
INSERT INTO `items` (`item_name`, `can_borrow_role`, `inSafe`) VALUES INSERT INTO `items` (`item_name`, `can_borrow_role`, `inSafe`) VALUES
('DJI 1er Mikro', 4, 1), ('DJI 1er Mikro', 4, 1),
('DJI 2er Mikro 1', 4, 1), ('DJI 2er Mikro 1', 4, 1),

View File

@@ -5,7 +5,7 @@ import apiRouter from "./routes/api.js";
import apiRouterV2 from "./routes/apiV2.js"; import apiRouterV2 from "./routes/apiV2.js";
env.config(); env.config();
const app = express(); const app = express();
const port = 8002; const port = 8102;
app.use(cors()); app.use(cors());
// Increase body size limits to support large CSV JSON payloads // Increase body size limits to support large CSV JSON payloads

View File

@@ -8,6 +8,7 @@ const pool = mysql
user: process.env.DB_USER, user: process.env.DB_USER,
password: process.env.DB_PASSWORD, password: process.env.DB_PASSWORD,
database: process.env.DB_NAME, database: process.env.DB_NAME,
port: process.env.DB_PORT,
}) })
.promise(); .promise();
@@ -51,22 +52,56 @@ export const changeInSafeStateV2 = async (itemId) => {
}; };
export const setReturnDateV2 = async (loanCode) => { export const setReturnDateV2 = async (loanCode) => {
const [items] = await pool.query(
"SELECT loaned_items_id 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 inSafe = 1 WHERE id IN (?)",
[itemIds]
);
const [result] = await pool.query( const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?", "UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
[loanCode] [loanCode]
); );
if (result.affectedRows > 0) {
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true }; return { success: true };
} }
return { success: false }; return { success: false };
}; };
export const setTakeDateV2 = async (loanCode) => { export const setTakeDateV2 = async (loanCode) => {
const [items] = await pool.query(
"SELECT loaned_items_id 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 inSafe = 0 WHERE id IN (?)",
[itemIds]
);
const [result] = await pool.query( const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?", "UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
[loanCode] [loanCode]
); );
if (result.affectedRows > 0) {
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true }; return { success: true };
} }
return { success: false }; return { success: false };
@@ -148,6 +183,16 @@ export const getBorrowableItemsFromDatabase = async (
return { success: false }; return { success: false };
}; };
export const getLoanInfoWithID = async (loanId) => {
const [rows] = await pool.query("SELECT * FROM loans WHERE id = ?;", [
loanId,
]);
if (rows.length > 0) {
return { success: true, data: rows[0] };
}
return { success: false };
};
export const createLoanInDatabase = async ( export const createLoanInDatabase = async (
username, username,
startDate, startDate,
@@ -457,3 +502,36 @@ export const getAllLoansV2 = async () => {
} }
return { success: false }; return { success: false };
}; };
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, user) => {
const [result] = await pool.query(
"INSERT INTO apiKeys (apiKey, user) VALUES (?, ?)",
[apiKey, user]
);
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 };
};
export const getAPIkey = async () => {
const [rows] = await pool.query("SELECT apiKey FROM apiKeys");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};

View File

@@ -1,35 +1,45 @@
services: services:
# borrow_system-frontend: borrow_system-frontend:
# container_name: borrow_system-frontend container_name: borrow_system-frontend
# build: ./frontend build: ./frontend
# ports: ports:
# - "8001:8001" - "8101:8101"
# environment: networks:
# - CHOKIDAR_USEPOLLING=true - proxynet
# volumes: - borrow_system-internal
# - ./frontend:/app environment:
# - /app/node_modules - CHOKIDAR_USEPOLLING=true
# restart: unless-stopped volumes:
- ./frontend:/app
- /app/node_modules
restart: unless-stopped
# admin-frontend: admin-frontend:
# container_name: admin-frontend container_name: admin-frontend
# build: ./admin build: ./admin
# ports: networks:
# - "8003:8003" - proxynet
# environment: - borrow_system-internal
# - CHOKIDAR_USEPOLLING=true ports:
# volumes: - "8103:8103"
# - ./admin:/app environment:
# - /app/node_modules - CHOKIDAR_USEPOLLING=true
# restart: unless-stopped volumes:
- ./admin:/app
- /app/node_modules
restart: unless-stopped
borrow_system-backend: borrow_system-backend:
container_name: borrow_system-backend container_name: borrow_system-backend
build: ./backend build: ./backend
ports: ports:
- "8002:8002" - "8102:8102"
networks:
- proxynet
- borrow_system-internal
environment: environment:
DB_HOST: mysql DB_HOST: mysql
DB_PORT: 3306
DB_USER: root DB_USER: root
DB_PASSWORD: ${DB_PASSWORD} DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: borrow_system DB_NAME: borrow_system
@@ -52,6 +62,14 @@ services:
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro - ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
ports: ports:
- "3309:3306" - "3309:3306"
networks:
- borrow_system-internal
volumes: volumes:
mysql-data: mysql-data:
networks:
proxynet:
external: true
borrow_system-internal:
external: false

View File

@@ -7,6 +7,6 @@ RUN npm install
COPY . . COPY . .
EXPOSE 8001 EXPOSE 8101
CMD ["npm", "run", "dev"] CMD ["npm", "run", "dev"]

View File

@@ -19,6 +19,11 @@ type Loan = {
loaned_items_name: string[]; loaned_items_name: string[];
}; };
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
const formatDate = (iso: string | null) => { const formatDate = (iso: string | null) => {
if (!iso) return "-"; if (!iso) return "-";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/); const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
@@ -28,7 +33,7 @@ const formatDate = (iso: string | null) => {
}; };
async function fetchUserLoans(): Promise<Loan[]> { async function fetchUserLoans(): Promise<Loan[]> {
const res = await fetch("http://localhost:8002/api/userLoans", { const res = await fetch(`${API_BASE}/api/userLoans`, {
method: "GET", method: "GET",
headers: { Authorization: `Bearer ${Cookies.get("token") || ""}` }, headers: { Authorization: `Bearer ${Cookies.get("token") || ""}` },
}); });

View File

@@ -6,6 +6,11 @@ export const ALL_ITEMS_UPDATED_EVENT = "allItemsUpdated";
export const BORROWABLE_ITEMS_UPDATED_EVENT = "borrowableItemsUpdated"; export const BORROWABLE_ITEMS_UPDATED_EVENT = "borrowableItemsUpdated";
export const AUTH_LOGOUT_EVENT = "authLogout"; export const AUTH_LOGOUT_EVENT = "authLogout";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
let sendError = false; let sendError = false;
function logout() { function logout() {
@@ -25,7 +30,7 @@ export const fetchAllData = async (token: string | undefined) => {
if (!token) return; if (!token) return;
// First we fetch all items that are potentially available for borrowing // First we fetch all items that are potentially available for borrowing
try { try {
const response = await fetch("http://localhost:8002/api/items", { const response = await fetch(`${API_BASE}/api/items`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
@@ -57,7 +62,7 @@ export const fetchAllData = async (token: string | undefined) => {
// get all loans // get all loans
try { try {
const response = await fetch("http://localhost:8002/api/loans", { const response = await fetch(`${API_BASE}/api/loans`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
@@ -89,7 +94,7 @@ export const fetchAllData = async (token: string | undefined) => {
// get user loans // get user loans
try { try {
const response = await fetch("http://localhost:8002/api/userLoans", { const response = await fetch(`${API_BASE}/api/userLoans`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${token}`, Authorization: `Bearer ${token}`,
@@ -122,7 +127,7 @@ export const fetchAllData = async (token: string | undefined) => {
export const loginUser = async (username: string, password: string) => { export const loginUser = async (username: string, password: string) => {
try { try {
const response = await fetch("http://localhost:8002/api/login", { const response = await fetch(`${API_BASE}/api/login`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -158,7 +163,7 @@ export const getBorrowableItems = async () => {
} }
try { try {
const response = await fetch("http://localhost:8002/api/borrowableItems", { const response = await fetch(`${API_BASE}/api/borrowableItems`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`, Authorization: `Bearer ${Cookies.get("token") || ""}`,

View File

@@ -2,10 +2,15 @@ import { myToast } from "./toastify";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { queryClient } from "./queryClient"; import { queryClient } from "./queryClient";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
export const handleDeleteLoan = async (loanID: number): Promise<boolean> => { export const handleDeleteLoan = async (loanID: number): Promise<boolean> => {
try { try {
const response = await fetch( const response = await fetch(
`http://localhost:8002/api/deleteLoan/${loanID}`, `${API_BASE}/api/deleteLoan/${loanID}`,
{ {
method: "DELETE", method: "DELETE",
headers: { headers: {
@@ -75,7 +80,7 @@ export const rmFromRemove = (itemID: number) => {
export const createLoan = async (startDate: string, endDate: string) => { export const createLoan = async (startDate: string, endDate: string) => {
const items = removeArr; const items = removeArr;
const response = await fetch("http://localhost:8002/api/createLoan", { const response = await fetch(`${API_BASE}/api/createLoan`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -103,7 +108,7 @@ export const createLoan = async (startDate: string, endDate: string) => {
export const onReturn = async (loanID: number) => { export const onReturn = async (loanID: number) => {
const response = await fetch( const response = await fetch(
`http://localhost:8002/api/returnLoan/${loanID}`, `${API_BASE}/api/returnLoan/${loanID}`,
{ {
method: "POST", method: "POST",
headers: { headers: {
@@ -122,7 +127,7 @@ export const onReturn = async (loanID: number) => {
}; };
export const onTake = async (loanID: number) => { export const onTake = async (loanID: number) => {
const response = await fetch(`http://localhost:8002/api/takeLoan/${loanID}`, { const response = await fetch(`${API_BASE}/api/takeLoan/${loanID}`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`, Authorization: `Bearer ${Cookies.get("token") || ""}`,
@@ -139,7 +144,7 @@ export const onTake = async (loanID: number) => {
}; };
export const changePW = async (oldPassword: string, newPassword: string) => { export const changePW = async (oldPassword: string, newPassword: string) => {
const response = await fetch("http://localhost:8002/api/changePassword", { const response = await fetch(`${API_BASE}/api/changePassword`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",

View File

@@ -1,15 +1,17 @@
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
export default defineConfig({ export default defineConfig({
plugins: [react(), svgr(), tailwindcss()], plugins: [tailwindcss()],
server: { server: {
host: "0.0.0.0", host: "0.0.0.0",
port: 8001, allowedHosts: ["insta.the1s.de"],
watch: { port: 8101,
usePolling: true, watch: { usePolling: true },
hmr: {
host: "insta.the1s.de",
port: 8101,
protocol: "wss",
}, },
}, },
}); });