Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a3c948386 | |||
| 6fb03530df | |||
| d2c36e71be | |||
| 40d784ab36 | |||
| 60c85efd37 | |||
| 747932cf03 | |||
| 0964109c4b | |||
| 5442f2f1f3 | |||
| 56e073244f | |||
| d2dc74971f | |||
| 25709ea0d9 | |||
| f8ab2490fe | |||
| 3de877dd2b | |||
| 5d0134017a | |||
| 07503ec079 | |||
| 4155982aac | |||
| 59e05e5c4c | |||
| 2facdab011 | |||
| 04db0bd7e0 | |||
| 40dd34bcac | |||
| 939d3e89c5 | |||
| a355c42964 | |||
| c8a230979f | |||
| 581cd4a1fd | |||
| c53e6e095a | |||
| eaa325668c | |||
| 1fa8b4a9a7 | |||
| 3ba3c1c0cb | |||
| ee54d51f8b | |||
| a8dab549af | |||
| 06976f7972 |
@@ -116,4 +116,9 @@ ToDo.txt
|
||||
|
||||
|
||||
# only in development branch
|
||||
next-env.d.ts
|
||||
next-env.d.ts
|
||||
|
||||
# psd files from footage
|
||||
footage/*.psd
|
||||
|
||||
icon/
|
||||
@@ -0,0 +1,3 @@
|
||||
[submodule "no-as-a-service"]
|
||||
path = no-as-a-service
|
||||
url = https://github.com/hotheadhacker/no-as-a-service.git
|
||||
@@ -1,366 +1,177 @@
|
||||
# Borrow System API Documentation
|
||||
|
||||
**Frontend:** https://insta.the1s.de
|
||||
**Backend base URL:** `https://insta.the1s.de/backend/api`
|
||||
## Overview
|
||||
|
||||
---
|
||||
The Borrow System API provides endpoints for managing items, loans, and door access for a borrowing/locker system. All endpoints require authentication via an 8-digit API key passed as a URL parameter.
|
||||
|
||||
## Authentication
|
||||
|
||||
All API endpoints require **either**:
|
||||
|
||||
### 1. Bearer Token (JWT)
|
||||
|
||||
Send an `Authorization` header:
|
||||
|
||||
```http
|
||||
Authorization: Bearer <JWT_TOKEN>
|
||||
```
|
||||
|
||||
- Used for user-based access.
|
||||
- Token must be valid and not expired.
|
||||
|
||||
### 2. API Key (for devices / machine-to-machine)
|
||||
|
||||
Include an API key in the route as `:key` parameter:
|
||||
|
||||
```text
|
||||
/api/.../:key/...
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```http
|
||||
GET /api/items/12345678
|
||||
```
|
||||
|
||||
Where `12345678` is your API key.
|
||||
The API key is validated server-side.
|
||||
|
||||
---
|
||||
|
||||
## Common Response Codes
|
||||
|
||||
- `200 OK` – Request was successful.
|
||||
- `401 Unauthorized` – Missing or malformed credentials.
|
||||
- `403 Forbidden` – Credentials invalid or not allowed to access this resource.
|
||||
- `404 Not Found` – Resource (e.g., loan) not found.
|
||||
- `500 Internal Server Error` – Unexpected server error.
|
||||
|
||||
---
|
||||
All requests must include a valid API key in the URL path as the `:key` parameter. API keys are 8-digit numeric strings.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### 1. Get All Items
|
||||
The Base URL for all endpoints is: `https://insta.the1s.de/backend/api`
|
||||
|
||||
**GET** `/api/items/:key`
|
||||
### Get All Items
|
||||
|
||||
Returns a list of all items.
|
||||
`GET /items/:key`
|
||||
|
||||
#### Path Parameters
|
||||
Returns all items in the system.
|
||||
|
||||
- `:key` – API key (8-digit number)
|
||||
|
||||
#### Authentication
|
||||
|
||||
- Either:
|
||||
- Valid `Authorization: Bearer <token>`
|
||||
- Or valid `:key` path parameter
|
||||
|
||||
#### Request Example
|
||||
|
||||
```http
|
||||
GET /api/items/12345678 HTTP/1.1
|
||||
Host: backend.insta.the1s.de
|
||||
Authorization: Bearer <JWT_TOKEN>
|
||||
```
|
||||
|
||||
#### Successful Response (200)
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"item_name": "DJI 1er Mikro",
|
||||
"can_borrow_role": 4,
|
||||
"inSafe": 1,
|
||||
"item_name": "Laptop",
|
||||
"can_borrow_role": 1,
|
||||
"in_safe": true,
|
||||
"safe_nr": 3,
|
||||
"door_key": "123",
|
||||
"entry_created_at": "2025-08-19T22:02:16.000Z",
|
||||
"entry_updated_at": "2025-08-19T22:02:16.000Z",
|
||||
"last_borrowed_person": "alice",
|
||||
"door_key": 101,
|
||||
"last_borrowed_person": "jdoe",
|
||||
"currently_borrowing": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Error Response (500)
|
||||
**Response 500:**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Failed to fetch items"
|
||||
}
|
||||
{ "message": "Failed to fetch items" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Toggle Item Safe State
|
||||
### Change Item Safe State
|
||||
|
||||
Toggles `in_safe` between `0` and `1` for a given item.
|
||||
`POST /change-state/:key/:itemId`
|
||||
|
||||
**Keep in mind that when you return a loan by code, the item states are automatically updated.**
|
||||
Toggles the `in_safe` boolean state of an item.
|
||||
|
||||
**POST** `/api/change-state/:key/:itemId`
|
||||
**URL Parameters:**
|
||||
|
||||
#### Path Parameters
|
||||
- **key** - API key
|
||||
- **itemId** - The item's ID
|
||||
|
||||
- `:key` – API key (8-digit number)
|
||||
- `:itemId` – Item ID (integer)
|
||||
**Response 200:** Returns on successful toggle.
|
||||
|
||||
#### Authentication
|
||||
|
||||
- Either Bearer token or `:key` API key.
|
||||
|
||||
#### Request Example
|
||||
|
||||
```http
|
||||
POST /api/change-state/12345678/42 HTTP/1.1
|
||||
Host: backend.insta.the1s.de
|
||||
```
|
||||
|
||||
#### Successful Response (200)
|
||||
**Response 500:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
_(Implementation currently only returns `{ success: true }`, so `data` may be empty.)_
|
||||
|
||||
#### Error Response (500)
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Failed to update item state"
|
||||
}
|
||||
{ "message": "Failed to update item state" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Get Loan by Code
|
||||
### Get Loan by Code
|
||||
|
||||
Fetch loan information by `loan_code`.
|
||||
`GET /get-loan-by-code/:key/:loan_code`
|
||||
|
||||
**GET** `/api/get-loan-by-code/:key/:loan_code`
|
||||
Retrieves loan details by its 6-digit loan code.
|
||||
|
||||
#### Path Parameters
|
||||
**URL Parameters:**
|
||||
|
||||
- `:key` – API key (8-digit number)
|
||||
- `:loan_code` – Loan code (string)
|
||||
- **key** - API key
|
||||
- **loan_code** - A 6-digit numeric loan code
|
||||
|
||||
#### Authentication
|
||||
|
||||
- Either Bearer token or `:key` API key.
|
||||
|
||||
#### Request Example
|
||||
|
||||
```http
|
||||
GET /api/get-loan-by-code/12345678/12345 HTTP/1.1
|
||||
Host: backend.insta.the1s.de
|
||||
```
|
||||
|
||||
#### Successful Response (200)
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"username": "john",
|
||||
"username": "jdoe",
|
||||
"returned_date": null,
|
||||
"take_date": "2025-01-01T10:00:00.000Z",
|
||||
"lockers": "[1, 2, 3]"
|
||||
"take_date": "2024-01-15T10:30:00.000Z",
|
||||
"lockers": [1, 3]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Error Response (404)
|
||||
**Response 404:**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Loan not found"
|
||||
}
|
||||
{ "message": "Loan not found" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Set Loan Return Date
|
||||
### Set Take Date
|
||||
|
||||
Sets `returned_date = NOW()` on a loan and updates related items:
|
||||
`POST /set-take-date/:key/:loan_code`
|
||||
|
||||
- `in_safe = 1`
|
||||
- `currently_borrowing = NULL`
|
||||
- `last_borrowed_person = username`
|
||||
Records when items are physically taken by setting `take_date` to the current timestamp. Updates associated items to `in_safe = false` and sets `currently_borrowing` to the loan's username.
|
||||
|
||||
**POST** `/api/set-return-date/:key/:loan_code`
|
||||
**URL Parameters:**
|
||||
|
||||
#### Path Parameters
|
||||
- **key** - API key
|
||||
- **loan_code** - A 6-digit numeric loan code
|
||||
|
||||
- `:key` – API key (8-digit number)
|
||||
- `:loan_code` – Loan code (string)
|
||||
**Response 200:** Empty JSON object on success.
|
||||
|
||||
#### Authentication
|
||||
|
||||
- Either Bearer token or `:key` API key.
|
||||
|
||||
#### Request Example
|
||||
|
||||
```http
|
||||
POST /api/set-return-date/12345678/12345 HTTP/1.1
|
||||
Host: backend.insta.the1s.de
|
||||
```
|
||||
|
||||
#### Successful Response (200)
|
||||
**Response 500:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {}
|
||||
}
|
||||
{ "message": "Loan not found or already taken" }
|
||||
```
|
||||
|
||||
#### Error Response (500)
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Failed to set return date"
|
||||
}
|
||||
```
|
||||
> **Note:** This endpoint will fail if the loan has already been taken or does not exist.
|
||||
|
||||
---
|
||||
|
||||
### 5. Set Loan Take Date
|
||||
### Set Return Date
|
||||
|
||||
Sets `take_date = NOW()` on a loan and updates related items:
|
||||
`POST /set-return-date/:key/:loan_code`
|
||||
|
||||
- `in_safe = 0`
|
||||
- `currently_borrowing = username`
|
||||
Marks a loan as returned by setting `returned_date` to the current timestamp. Also updates all associated items to `in_safe = true`, clears `currently_borrowing`, and sets `last_borrowed_person`. Therefore, keep in mind that you must not call other endpoints that will change the safe state of an item after or before calling this endpoint, otherwise the state of the items will be inconsistent.
|
||||
|
||||
**POST** `/api/set-take-date/:key/:loan_code`
|
||||
**URL Parameters:**
|
||||
|
||||
#### Path Parameters
|
||||
- **key** - API key
|
||||
- **loan_code** - A 6-digit numeric loan code
|
||||
|
||||
- `:key` – API key (8-digit number)
|
||||
- `:loan_code` – Loan code (string)
|
||||
**Response 200:** Empty JSON object on success.
|
||||
|
||||
#### Authentication
|
||||
|
||||
- Either Bearer token or `:key` API key.
|
||||
|
||||
#### Request Example
|
||||
|
||||
```http
|
||||
POST /api/set-take-date/12345678/LOAN-12345 HTTP/1.1
|
||||
Host: backend.insta.the1s.de
|
||||
```
|
||||
|
||||
#### Successful Response (200)
|
||||
**Response 500:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {}
|
||||
}
|
||||
{ "message": "Failed to set return date" }
|
||||
```
|
||||
|
||||
#### Error Response (500)
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Failed to set take date"
|
||||
}
|
||||
```
|
||||
> **Note:** This endpoint will fail if the loan has already been returned (i.e., `returned_date` is not `NULL`).
|
||||
|
||||
---
|
||||
|
||||
### 6. Open Door by Door Key
|
||||
### Open Door
|
||||
|
||||
Looks up an item by its `door_key`, toggles `in_safe`, and returns safe information.
|
||||
`GET /open-door/:key/:doorKey`
|
||||
|
||||
**GET** `/api/open-door/:key/:doorKey`
|
||||
Toggles the safe state of an item identified by its door key and returns the associated safe number.
|
||||
|
||||
#### Path Parameters
|
||||
**URL Parameters:**
|
||||
|
||||
- `:key` – API key (8-digit number)
|
||||
- `:doorKey` – Door key/token (string) used by hardware to identify the locker.
|
||||
- **key** - API key
|
||||
- **doorKey** - The door key identifier assigned to an item
|
||||
|
||||
#### Authentication
|
||||
|
||||
- Either Bearer token or `:key` API key.
|
||||
|
||||
#### Request Example
|
||||
|
||||
```http
|
||||
GET /api/open-door/12345678/123 HTTP/1.1
|
||||
Host: backend.insta.the1s.de
|
||||
```
|
||||
|
||||
#### Successful Response (200)
|
||||
**Response 200:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"safe_nr": 5,
|
||||
"id": 42
|
||||
"safe_nr": 3,
|
||||
"id": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Error Response (500)
|
||||
**Response 500:**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Failed to open door"
|
||||
}
|
||||
{ "message": "Failed to open door" }
|
||||
```
|
||||
|
||||
---
|
||||
## Error Handling
|
||||
|
||||
## Authentication Error Messages
|
||||
|
||||
### Missing credentials
|
||||
|
||||
Status: `401`
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Unauthorized"
|
||||
}
|
||||
```
|
||||
|
||||
### Invalid JWT
|
||||
|
||||
Status: `403`
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Present token invalid"
|
||||
}
|
||||
```
|
||||
|
||||
### Invalid API Key
|
||||
|
||||
Status: `403`
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "API Key invalid"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- All responses are JSON.
|
||||
- Time fields like `take_date` and `returned_date` are in the format returned by MySQL (usually ISO-like strings).
|
||||
- `loaned_items_id` in the database is stored as a JSON array string (e.g. `"[1,2,3]"`) and is parsed internally; clients do not interact with this field directly via current endpoints.
|
||||
All endpoints return a `500` status code for server-side failures and a JSON body with a `message` field, except for **Get Loan by Code** which returns `404` when no matching loan is found.
|
||||
|
||||
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 428 KiB |
|
After Width: | Height: | Size: 416 KiB |
@@ -2,7 +2,11 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="/icon_borrow-system-frontend_dark.png"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Ausleihsystem</title>
|
||||
</head>
|
||||
|
||||
@@ -14,7 +14,7 @@ server {
|
||||
}
|
||||
|
||||
location /backend/ {
|
||||
proxy_pass http://demo_borrow_system-backend_v2:8102/;
|
||||
proxy_pass http://borrow_system-backend_v2:8004/;
|
||||
}
|
||||
|
||||
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "v2.1.2 (dev)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@chakra-ui/react": "^3.28.0",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@lottiefiles/dotlottie-react": "^0.19.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/react-query": "^5.90.5",
|
||||
"i18next": "^25.6.0",
|
||||
|
||||
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-shapes-icon lucide-shapes"><path d="M8.3 10a.7.7 0 0 1-.626-1.079L11.4 3a.7.7 0 0 1 1.198-.043L16.3 8.9a.7.7 0 0 1-.572 1.1Z"/><rect x="3" y="14" width="7" height="7" rx="1"/><circle cx="17.5" cy="17.5" r="3.5"/></svg>
|
||||
|
Before Width: | Height: | Size: 420 B |
@@ -12,10 +12,11 @@ import { triggerLogoutAtom } from "@/states/Atoms";
|
||||
import { MyLoansPage } from "./pages/MyLoansPage";
|
||||
import Landingpage from "./pages/Landingpage";
|
||||
import { changeLanguage } from "i18next";
|
||||
import { Box, Flex } from "@chakra-ui/react";
|
||||
import { Flex } from "@chakra-ui/react";
|
||||
import { Footer } from "./components/footer/Footer";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { API_BASE } from "@/config/api.config";
|
||||
import { ContactPage } from "./pages/ContactPage";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -71,8 +72,8 @@ function App() {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Flex direction="column" minH="100vh">
|
||||
<Box as="main" flex="1">
|
||||
<Flex direction="column" minH="100dvh">
|
||||
<Flex as="main" flex="1" direction="column">
|
||||
<UserContext.Provider value={user}>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
@@ -80,13 +81,14 @@ function App() {
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/my-loans" element={<MyLoansPage />} />
|
||||
<Route path="/landingpage" element={<Landingpage />} />
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</UserContext.Provider>
|
||||
</Box>
|
||||
</Flex>
|
||||
<Footer />
|
||||
</Flex>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Alert, Stack, VStack, Spinner, Text, Heading } from "@chakra-ui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { API_BASE } from "@/config/api.config";
|
||||
import Cookies from "js-cookie";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const DeactivatedServices = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [deactivatedServices, setDeactivatedServices] = useState<
|
||||
{ function_name: string }[]
|
||||
>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchDeactivatedServices = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/users/deactivated-services`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setDeactivatedServices(data);
|
||||
} else {
|
||||
console.error("Failed to fetch deactivated services");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching deactivated services:", error);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
fetchDeactivatedServices();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{deactivatedServices.length >= 1 && (
|
||||
<Stack gap="2">
|
||||
<Heading size={"xl"}>{t("deactivated-services")}</Heading>
|
||||
{isLoading && (
|
||||
<VStack colorPalette="teal">
|
||||
<Spinner color="colorPalette.600" />
|
||||
<Text color="colorPalette.600">{t("loading")}</Text>
|
||||
</VStack>
|
||||
)}
|
||||
{deactivatedServices.length >= 1 &&
|
||||
deactivatedServices.map((item) => (
|
||||
<Alert.Root key={item.function_name} status="warning">
|
||||
<Alert.Indicator />
|
||||
<Alert.Title>
|
||||
{item.function_name} {t("is-deactivated")}
|
||||
</Alert.Title>
|
||||
</Alert.Root>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Button,
|
||||
Flex,
|
||||
Image,
|
||||
Heading,
|
||||
Stack,
|
||||
Text,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
MoreVertical,
|
||||
Languages,
|
||||
Table,
|
||||
ContactRound,
|
||||
} from "lucide-react";
|
||||
import { useUserContext } from "@/states/Context";
|
||||
import { useState } from "react";
|
||||
@@ -67,6 +69,7 @@ export const Header = () => {
|
||||
className="mb-6"
|
||||
position="relative"
|
||||
pr={{ base: 10, md: 0 }} // Platz für den Mobile-Button rechts
|
||||
marginBottom={1}
|
||||
>
|
||||
{/* Mobile: Drei-Punkte-Button, vertikal zentriert im Header */}
|
||||
<Box
|
||||
@@ -141,7 +144,7 @@ export const Header = () => {
|
||||
value="help"
|
||||
onSelect={() =>
|
||||
window.open(
|
||||
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki",
|
||||
"https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki/?action=_pages",
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
)
|
||||
@@ -153,6 +156,16 @@ export const Header = () => {
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
<Menu.Item
|
||||
value="contact"
|
||||
onSelect={() => navigate("/contact", { replace: true })}
|
||||
children={
|
||||
<HStack gap={3}>
|
||||
<ContactRound size={16} />
|
||||
<Text as="span">{t("contact")}</Text>
|
||||
</HStack>
|
||||
}
|
||||
/>
|
||||
<Menu.Separator />
|
||||
<Menu.Item
|
||||
value="logout"
|
||||
@@ -179,6 +192,13 @@ export const Header = () => {
|
||||
<Stack gap={1}>
|
||||
{/* Titelzeile ohne Mobile-Menu (wurde nach oben verlegt) */}
|
||||
<Flex align="center" justify="space-between" gap={2}>
|
||||
<Image
|
||||
src="/icon_borrow-system-frontend_dark.png"
|
||||
alt="borrow-system logo"
|
||||
boxSize="10"
|
||||
objectFit="contain"
|
||||
flexShrink={0}
|
||||
/>
|
||||
<Heading
|
||||
size="2xl"
|
||||
className="tracking-tight text-slate-900 dark:text-slate-100"
|
||||
@@ -268,7 +288,7 @@ export const Header = () => {
|
||||
</Button>
|
||||
|
||||
<a
|
||||
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki"
|
||||
href="https://git.the1s.de/Matthias-Claudius-Schule/borrow-system/wiki/?action=_pages"
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="ghost">
|
||||
@@ -278,6 +298,17 @@ export const Header = () => {
|
||||
</HStack>
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
<Button
|
||||
variant={"outline"}
|
||||
onClick={() => navigate("/contact", { replace: true })}
|
||||
>
|
||||
<HStack gap={2}>
|
||||
<ContactRound size={18} />
|
||||
<Text as="span">{t("contact")}</Text>
|
||||
</HStack>
|
||||
</Button>
|
||||
|
||||
<Button onClick={logout} variant="outline" colorScheme="red">
|
||||
<HStack gap={2}>
|
||||
<LogOut size={18} />
|
||||
|
||||
@@ -36,12 +36,43 @@ export const UserDialogue = (props: UserDialogueProps) => {
|
||||
const [msgTitle, setMsgTitle] = useState("");
|
||||
const [msgDescription, setMsgDescription] = useState("");
|
||||
|
||||
const [isMsgNAAS, setIsMsgNAAS] = useState(false);
|
||||
const [msgStatusNAAS, setMsgStatusNAAS] = useState<"error" | "success">(
|
||||
"error",
|
||||
);
|
||||
const [msgTitleNAAS, setMsgTitleNAAS] = useState("");
|
||||
const [msgDescriptionNAAS, setMsgDescriptionNAAS] = useState("");
|
||||
|
||||
const [oldPassword, setOldPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
|
||||
// Dialog control
|
||||
const [isPwOpen, setPwOpen] = useState(false);
|
||||
const [naasDialog, setNaasDialog] = useState(false);
|
||||
const [naas, setNaas] = useState("");
|
||||
|
||||
const openNAAS = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/no`, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
setNaas(data.reason);
|
||||
setNaasDialog(true);
|
||||
} catch (error) {
|
||||
setMsgStatusNAAS("error");
|
||||
setMsgTitleNAAS(t("naas-error"));
|
||||
setMsgDescriptionNAAS(t("naas-error-desc"));
|
||||
setIsMsgNAAS(true);
|
||||
|
||||
console.log(msgStatusNAAS, msgTitleNAAS, msgDescriptionNAAS);
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = async () => {
|
||||
if (newPassword !== confirmPassword) {
|
||||
@@ -147,14 +178,31 @@ export const UserDialogue = (props: UserDialogueProps) => {
|
||||
</Button>
|
||||
</Stack>
|
||||
</Card.Body>
|
||||
<Card.Footer justifyContent="flex-end">
|
||||
<Button variant="outline" onClick={() => props.setUserDialog(false)}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Card.Footer>
|
||||
<Stack w="100%" gap={3}>
|
||||
{isMsgNAAS && (
|
||||
<MyAlert
|
||||
status={msgStatusNAAS}
|
||||
title={msgTitleNAAS}
|
||||
description={msgDescriptionNAAS}
|
||||
/>
|
||||
)}
|
||||
<HStack justify="flex-end" gap={2} wrap="wrap">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => props.setUserDialog(false)}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => openNAAS()}>
|
||||
{t("try-naas")}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Stack>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
{/* Passwort-Dialog (kontrolliert) */}
|
||||
{/* Passwort-Dialog */}
|
||||
<Dialog.Root open={isPwOpen} onOpenChange={(e: any) => setPwOpen(e.open)}>
|
||||
<Portal>
|
||||
<Dialog.Backdrop />
|
||||
@@ -215,6 +263,31 @@ export const UserDialogue = (props: UserDialogueProps) => {
|
||||
</Dialog.Positioner>
|
||||
</Portal>
|
||||
</Dialog.Root>
|
||||
|
||||
<HStack wrap="wrap" gap="4">
|
||||
<Dialog.Root
|
||||
placement={"center"}
|
||||
open={naasDialog}
|
||||
motionPreset="slide-in-bottom"
|
||||
>
|
||||
<Portal>
|
||||
<Dialog.Backdrop />
|
||||
<Dialog.Positioner>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>{t("naas-header")}</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
<Dialog.Body>
|
||||
<p>{naas}</p>
|
||||
</Dialog.Body>
|
||||
<Dialog.CloseTrigger asChild>
|
||||
<CloseButton onClick={() => setNaasDialog(false)} size="sm" />
|
||||
</Dialog.CloseTrigger>
|
||||
</Dialog.Content>
|
||||
</Dialog.Positioner>
|
||||
</Portal>
|
||||
</Dialog.Root>
|
||||
</HStack>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { DotLottieReact } from "@lottiefiles/dotlottie-react";
|
||||
|
||||
export const unlockAnimation = () => {
|
||||
return (
|
||||
<DotLottieReact
|
||||
src="https://lottie.host/f839baa1-9c64-44c4-9386-f0e4c87ab208/2Iw1m4k86d.lottie"
|
||||
autoplay
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const approvalAnimation = () => {
|
||||
return (
|
||||
<DotLottieReact
|
||||
src="https://lottie.host/b7257009-9e3f-43e2-8112-a176f4696e4c/iQxxqAVOGX.lottie"
|
||||
autoplay
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const logoutAnimation = () => {
|
||||
return (
|
||||
<DotLottieReact
|
||||
src="https://lottie.host/4975758c-de38-4d15-9f74-927709751d32/v8FtKpnD1y.lottie"
|
||||
autoplay
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,14 @@ export const Footer = () => {
|
||||
const { data: info } = useVersionInfoQuery();
|
||||
|
||||
return (
|
||||
<Box as="footer" py={4} textAlign="center" width="100%">
|
||||
<Box
|
||||
as="footer"
|
||||
py={4}
|
||||
textAlign="center"
|
||||
width="100%"
|
||||
flexShrink={0}
|
||||
fontSize="sm"
|
||||
>
|
||||
Made with ❤️ by Theis Gaedigk - Class of 2019 at MCS-Bochum
|
||||
<br />
|
||||
Frontend-Version: {info ? info["frontend-info"].version : "N/A"} |
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
"use client";
|
||||
"use client"
|
||||
|
||||
import { ChakraProvider, defaultSystem } from "@chakra-ui/react";
|
||||
import * as React from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { ColorModeProvider as ThemeColorModeProvider } from "./color-mode";
|
||||
import { ChakraProvider, defaultSystem } from "@chakra-ui/react"
|
||||
import {
|
||||
ColorModeProvider,
|
||||
type ColorModeProviderProps,
|
||||
} from "./color-mode"
|
||||
|
||||
export interface ColorModeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ColorModeProvider({ children }: ColorModeProviderProps) {
|
||||
// Wrap children with the real color-mode provider
|
||||
return <ThemeColorModeProvider>{children}</ThemeColorModeProvider>;
|
||||
}
|
||||
|
||||
export function Provider({ children }: { children: ReactNode }) {
|
||||
export function Provider(props: ColorModeProviderProps) {
|
||||
return (
|
||||
<ChakraProvider value={defaultSystem}>
|
||||
<ColorModeProvider>{children}</ColorModeProvider>
|
||||
<ColorModeProvider {...props} />
|
||||
</ChakraProvider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
Field,
|
||||
Textarea,
|
||||
Button,
|
||||
Alert,
|
||||
Container,
|
||||
Text,
|
||||
} from "@chakra-ui/react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState } from "react";
|
||||
import { API_BASE } from "@/config/api.config";
|
||||
import Cookies from "js-cookie";
|
||||
import { Header } from "@/components/Header";
|
||||
|
||||
interface Alert {
|
||||
type: "info" | "warning" | "success" | "error" | "neutral";
|
||||
headline: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export const ContactPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const [message, setMessage] = useState("");
|
||||
const [alert, setAlert] = useState<Alert | null>(null);
|
||||
|
||||
const sendMessage = async () => {
|
||||
// Logic to send the message
|
||||
const result = await fetch(`${API_BASE}/api/users/contact`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token") || ""}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
},
|
||||
body: JSON.stringify({ message }),
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
setAlert({
|
||||
type: "success",
|
||||
headline: t("contactPage_successHeadline"),
|
||||
text: t("contactPage_successText"),
|
||||
});
|
||||
setMessage("");
|
||||
} else if (result.status === 503) {
|
||||
setAlert({
|
||||
type: "error",
|
||||
headline: t("serviceDeactivatedHeadline"),
|
||||
text: t("contactPage_serviceDeactivatedText"),
|
||||
});
|
||||
} else {
|
||||
setAlert({
|
||||
type: "error",
|
||||
headline: t("contactPage_errorHeadline"),
|
||||
text: t("contactPage_errorText"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Container className="px-6 sm:px-8 pt-10">
|
||||
<Header />
|
||||
<Field.Root invalid={message === ""}>
|
||||
<Field.Label>
|
||||
<Text>{t("contactPage_messageDescription")}</Text>
|
||||
<Field.RequiredIndicator />
|
||||
</Field.Label>
|
||||
<Textarea
|
||||
placeholder={t("contactPage_messagePlaceholder")}
|
||||
variant="subtle"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
{message === "" && (
|
||||
<Field.ErrorText>{t("contactPage_messageErrorText")}</Field.ErrorText>
|
||||
)}
|
||||
</Field.Root>
|
||||
{alert && (
|
||||
<Alert.Root status={alert.type}>
|
||||
<Alert.Indicator />
|
||||
<Alert.Content>
|
||||
<Alert.Title>{alert.headline}</Alert.Title>
|
||||
<Alert.Description>{alert.text}</Alert.Description>
|
||||
</Alert.Content>
|
||||
</Alert.Root>
|
||||
)}
|
||||
<Button onClick={sendMessage}>{t("contactPage_sendButton")}</Button>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
@@ -18,6 +18,8 @@ import { borrowAbleItemsAtom } from "@/states/Atoms";
|
||||
import { createLoan } from "@/utils/Fetcher";
|
||||
import { Header } from "@/components/Header";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { approvalAnimation } from "@/components/dotLottie";
|
||||
import { DeactivatedServices } from "@/components/DeactivatedServices";
|
||||
|
||||
export interface User {
|
||||
username: string;
|
||||
@@ -27,6 +29,8 @@ export interface User {
|
||||
export const HomePage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [showAnimation, setShowAnimation] = useState(false);
|
||||
|
||||
const [borrowableItems, setBorrowableItems] = useAtom(borrowAbleItemsAtom);
|
||||
const [startDate, setStartDate] = useState("");
|
||||
const [endDate, setEndDate] = useState("");
|
||||
@@ -46,155 +50,173 @@ export const HomePage = () => {
|
||||
setSelectedItems((prevSelected) =>
|
||||
prevSelected.includes(itemId)
|
||||
? prevSelected.filter((id) => id !== itemId)
|
||||
: [...prevSelected, itemId]
|
||||
: [...prevSelected, itemId],
|
||||
);
|
||||
};
|
||||
|
||||
const showApprovalAnimation = (seconds: number) => {
|
||||
const milliseconds = seconds * 1000;
|
||||
|
||||
setShowAnimation(true);
|
||||
window.setTimeout(() => {
|
||||
setShowAnimation(false);
|
||||
}, milliseconds);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container className="px-6 sm:px-8 pt-10">
|
||||
<Header />
|
||||
{isMsg && (
|
||||
<MyAlert
|
||||
status={msgStatus}
|
||||
title={msgTitle}
|
||||
description={msgDescription}
|
||||
/>
|
||||
<>
|
||||
{showAnimation && (
|
||||
<div className="fixed inset-0 z-9999 flex items-center justify-center pointer-events-none">
|
||||
<div>{approvalAnimation()}</div>
|
||||
</div>
|
||||
)}
|
||||
<Stack as="main">
|
||||
<Text>{t("timezone-info")}</Text>
|
||||
<label htmlFor="startDate">
|
||||
<strong>
|
||||
<Text>{t("start-date")}</Text>
|
||||
</strong>
|
||||
</label>
|
||||
<Input
|
||||
id="startDate"
|
||||
placeholder={t("start-date")}
|
||||
type="datetime-local"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<label htmlFor="endDate">
|
||||
<strong>
|
||||
<Text>{t("end-date")}</Text>
|
||||
</strong>
|
||||
</label>
|
||||
<Input
|
||||
id="endDate"
|
||||
placeholder={t("end-date")}
|
||||
type="datetime-local"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setIsLoadingA(true);
|
||||
if (!startDate || !endDate) {
|
||||
setMsgStatus("error");
|
||||
setMsgTitle(t("missing-fields"));
|
||||
setMsgDescription(t("missing-fields-desc"));
|
||||
setIsMsg(true);
|
||||
setIsLoadingA(false);
|
||||
return;
|
||||
}
|
||||
await getBorrowableItems(startDate, endDate).then((response) => {
|
||||
setIsLoadingA(false);
|
||||
if (response && response.status === "error") {
|
||||
<Container className="px-6 sm:px-8 pt-10">
|
||||
<Header />
|
||||
<DeactivatedServices />
|
||||
{isMsg && (
|
||||
<MyAlert
|
||||
status={msgStatus}
|
||||
title={msgTitle}
|
||||
description={msgDescription}
|
||||
/>
|
||||
)}
|
||||
<Stack as="main">
|
||||
<Text>{t("timezone-info")}</Text>
|
||||
<label htmlFor="startDate">
|
||||
<strong>
|
||||
<Text>{t("start-date")}</Text>
|
||||
</strong>
|
||||
</label>
|
||||
<Input
|
||||
id="startDate"
|
||||
placeholder={t("start-date")}
|
||||
type="datetime-local"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<label htmlFor="endDate">
|
||||
<strong>
|
||||
<Text>{t("end-date")}</Text>
|
||||
</strong>
|
||||
</label>
|
||||
<Input
|
||||
id="endDate"
|
||||
placeholder={t("end-date")}
|
||||
type="datetime-local"
|
||||
value={endDate}
|
||||
onChange={(e) => setEndDate(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
setIsLoadingA(true);
|
||||
if (!startDate || !endDate) {
|
||||
setMsgStatus("error");
|
||||
setMsgTitle(response.title || t("error"));
|
||||
setMsgDescription(response.description || t("unknown-error"));
|
||||
setMsgTitle(t("missing-fields"));
|
||||
setMsgDescription(t("missing-fields-desc"));
|
||||
setIsMsg(true);
|
||||
setIsLoadingA(false);
|
||||
return;
|
||||
}
|
||||
setBorrowableItems(response.data);
|
||||
setIsMsg(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("get-borrowable-items")}
|
||||
</Button>
|
||||
{isLoadingA && (
|
||||
<VStack colorPalette="teal">
|
||||
<Spinner color="colorPalette.600" />
|
||||
<Text color="colorPalette.600">{t("loading")}</Text>
|
||||
</VStack>
|
||||
)}
|
||||
{borrowableItems.length > 0 && (
|
||||
<Table.ScrollArea borderWidth="1px" rounded="md">
|
||||
<Table.Root size="sm" stickyHeader>
|
||||
<Table.Header>
|
||||
<Table.Row bg="bg.subtle">
|
||||
<Table.ColumnHeader></Table.ColumnHeader>
|
||||
<Table.ColumnHeader>{t("item")}</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
|
||||
<Table.Body>
|
||||
{borrowableItems.map((item) => (
|
||||
<Table.Row key={item.id}>
|
||||
<Table.Cell>
|
||||
<input
|
||||
onChange={() => handleCheckboxChange(item.id)}
|
||||
type="checkbox"
|
||||
name={item.id}
|
||||
id={item.id}
|
||||
/>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{item.item_name}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
<Table.Row>
|
||||
<Table.Cell colSpan={2}>
|
||||
<InputGroup
|
||||
endElement={
|
||||
<Span color="fg.muted" textStyle="xs">
|
||||
{note.length} / {MAX_CHARACTERS}
|
||||
</Span>
|
||||
}
|
||||
>
|
||||
<Input
|
||||
placeholder={t("optional-note")}
|
||||
value={note}
|
||||
maxLength={MAX_CHARACTERS}
|
||||
onChange={(e) => {
|
||||
setNote(
|
||||
e.currentTarget.value.slice(0, MAX_CHARACTERS)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Table.ScrollArea>
|
||||
)}
|
||||
{selectedItems.length >= 1 && (
|
||||
<Button
|
||||
onClick={() =>
|
||||
createLoan(selectedItems, startDate, endDate, note).then(
|
||||
(response) => {
|
||||
if (response.status === "error") {
|
||||
setMsgStatus("error");
|
||||
setMsgTitle(response.title || t("error"));
|
||||
setMsgDescription(
|
||||
response.description || t("unknown-error")
|
||||
);
|
||||
setIsMsg(true);
|
||||
return;
|
||||
}
|
||||
setMsgStatus("success");
|
||||
setMsgTitle(t("success"));
|
||||
setMsgDescription(t("loan-success"));
|
||||
await getBorrowableItems(startDate, endDate).then((response) => {
|
||||
setIsLoadingA(false);
|
||||
if (response && response.status === "error") {
|
||||
setMsgStatus("error");
|
||||
setMsgTitle(response.title || t("error"));
|
||||
setMsgDescription(response.description || t("unknown-error"));
|
||||
setIsMsg(true);
|
||||
return;
|
||||
}
|
||||
)
|
||||
}
|
||||
setBorrowableItems(response.data);
|
||||
setIsMsg(false);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("create-loan")}
|
||||
{t("get-borrowable-items")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
{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;
|
||||
}
|
||||
showApprovalAnimation(3);
|
||||
setMsgStatus("success");
|
||||
setMsgTitle(t("success"));
|
||||
setMsgDescription(t("loan-success"));
|
||||
setIsMsg(true);
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("create-loan")}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -9,12 +9,13 @@ import {
|
||||
Card,
|
||||
SimpleGrid,
|
||||
Button,
|
||||
Container,
|
||||
} from "@chakra-ui/react";
|
||||
import MyAlert from "@/components/myChakra/MyAlert";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { API_BASE } from "@/config/api.config";
|
||||
import Cookies from "js-cookie";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Header } from "@/components/Header";
|
||||
|
||||
export const formatDateTime = (value: string | null | undefined) => {
|
||||
if (!value) return "N/A";
|
||||
@@ -32,6 +33,7 @@ type Loan = {
|
||||
returned_date: string | null;
|
||||
take_date: string | null;
|
||||
loaned_items_name: string[] | string;
|
||||
note: string | null;
|
||||
};
|
||||
|
||||
type Device = {
|
||||
@@ -46,7 +48,6 @@ type Device = {
|
||||
|
||||
const Landingpage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [loans, setLoans] = useState<Loan[]>([]);
|
||||
@@ -59,7 +60,7 @@ const Landingpage: React.FC = () => {
|
||||
const setError = (
|
||||
status: "error" | "success",
|
||||
message: string,
|
||||
description: string
|
||||
description: string,
|
||||
) => {
|
||||
setIsError(false);
|
||||
setErrorStatus(status);
|
||||
@@ -78,6 +79,16 @@ const Landingpage: React.FC = () => {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
});
|
||||
if (loanRes.status === 503) {
|
||||
setError(
|
||||
"error",
|
||||
t("serviceDeactivatedHeadline"),
|
||||
t("loan_page_serviceDeactivatedText"),
|
||||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const loanData = await loanRes.json();
|
||||
if (Array.isArray(loanData)) {
|
||||
setLoans(loanData);
|
||||
@@ -85,7 +96,7 @@ const Landingpage: React.FC = () => {
|
||||
setError(
|
||||
"error",
|
||||
t("error-by-loading"),
|
||||
t("unexpected-date-format_loan")
|
||||
t("unexpected-date-format_loan"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,7 +113,7 @@ const Landingpage: React.FC = () => {
|
||||
setError(
|
||||
"error",
|
||||
t("error-by-loading"),
|
||||
t("unexpected-date-format_device")
|
||||
t("unexpected-date-format_device"),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -115,14 +126,8 @@ const Landingpage: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading as="h1" size="lg" mb={2}>
|
||||
Matthias-Claudius-Schule Technik
|
||||
</Heading>
|
||||
|
||||
<Button onClick={() => navigate("/", { replace: true })}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<Container className="px-6 sm:px-8 pt-10">
|
||||
<Header />
|
||||
|
||||
<Heading as="h2" size="md" mb={4}>
|
||||
{t("all-loans")}
|
||||
@@ -168,6 +173,9 @@ const Landingpage: React.FC = () => {
|
||||
<Table.ColumnHeader>
|
||||
<strong>{t("return-date")}</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>{t("note")}</strong>
|
||||
</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
@@ -184,6 +192,7 @@ const Landingpage: React.FC = () => {
|
||||
</Table.Cell>
|
||||
<Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell>
|
||||
<Table.Cell>{formatDateTime(loan.returned_date)}</Table.Cell>
|
||||
<Table.Cell>{loan.note}</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
@@ -260,7 +269,7 @@ const Landingpage: React.FC = () => {
|
||||
</HStack>
|
||||
</Button>
|
||||
</HStack>
|
||||
</>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -4,27 +4,47 @@ import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
|
||||
import { setIsLoggedInAtom, triggerLogoutAtom } from "@/states/Atoms";
|
||||
import { useAtom } from "jotai";
|
||||
import Cookies from "js-cookie";
|
||||
import { Navigate, useNavigate, useLocation } from "react-router-dom";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Footer } from "@/components/footer/Footer";
|
||||
import { API_BASE } from "@/config/api.config";
|
||||
import { unlockAnimation } from "@/components/dotLottie";
|
||||
import { logoutAnimation } from "@/components/dotLottie";
|
||||
|
||||
export const LoginPage = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [isLoggedIn, setIsLoggedIn] = useAtom(setIsLoggedInAtom);
|
||||
const [triggerLogout, setTriggerLogout] = useAtom(triggerLogoutAtom);
|
||||
const [showAnimation, setShowAnimation] = useState(false);
|
||||
const [showLogout, setShowLogout] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const from = location.state?.from?.pathname || "/";
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoggedIn) {
|
||||
navigate(from, { replace: true });
|
||||
window.location.reload(); // if deleted, the user context is not updated in time
|
||||
if (triggerLogout) {
|
||||
setShowLogout(true);
|
||||
window.setTimeout(() => {
|
||||
setShowLogout(false);
|
||||
}, 4500);
|
||||
}
|
||||
}, [isLoggedIn, navigate, from]);
|
||||
|
||||
if (!isLoggedIn) return;
|
||||
|
||||
// Existing sessions should redirect immediately, fresh logins wait for animation.
|
||||
if (!showAnimation) {
|
||||
navigate(from, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
navigate(from, { replace: true });
|
||||
window.location.reload(); // keeps user context in sync after login
|
||||
}, 3000);
|
||||
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [isLoggedIn, showAnimation, navigate, from]);
|
||||
|
||||
const loginFnc = async (username: string, password: string) => {
|
||||
const response = await fetch(`${API_BASE}/api/users/login`, {
|
||||
@@ -43,6 +63,8 @@ export const LoginPage = () => {
|
||||
};
|
||||
}
|
||||
|
||||
setShowAnimation(true);
|
||||
|
||||
Cookies.set("token", data.token);
|
||||
setIsLoggedIn(true);
|
||||
return { success: true };
|
||||
@@ -63,59 +85,75 @@ export const LoginPage = () => {
|
||||
return;
|
||||
}
|
||||
setTriggerLogout(false);
|
||||
navigate(from, { replace: true });
|
||||
};
|
||||
|
||||
if (isLoggedIn) {
|
||||
return <Navigate to={from} replace />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<Card.Root maxW="sm">
|
||||
<Card.Header>
|
||||
<Card.Title>{t("login")}</Card.Title>
|
||||
<Card.Description>{t("enter-credentials")}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Stack gap="4" w="full">
|
||||
<Field.Root>
|
||||
<Field.Label>{t("username")}</Field.Label>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
<>
|
||||
{showAnimation && (
|
||||
<div className="fixed inset-0 z-9999 flex items-center justify-center pointer-events-none">
|
||||
<div>{unlockAnimation()}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLogout && (
|
||||
<div className="fixed inset-0 z-9999 flex items-center justify-center pointer-events-none">
|
||||
<div>{logoutAnimation()}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<Card.Root maxW="sm">
|
||||
<Card.Header>
|
||||
<Card.Title>{t("login")}</Card.Title>
|
||||
<Card.Description>{t("enter-credentials")}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Body>
|
||||
<Stack gap="4" w="full">
|
||||
<Field.Root>
|
||||
<Field.Label>{t("username")}</Field.Label>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</Field.Root>
|
||||
<Field.Root>
|
||||
<Field.Label>{t("password")}</Field.Label>
|
||||
<PasswordInput
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</Field.Root>
|
||||
</Stack>
|
||||
</Card.Body>
|
||||
<Card.Footer justifyContent="flex-end">
|
||||
{isError && (
|
||||
<MyAlert
|
||||
status="error"
|
||||
title={errorMsg}
|
||||
description={errorDsc}
|
||||
/>
|
||||
</Field.Root>
|
||||
<Field.Root>
|
||||
<Field.Label>{t("password")}</Field.Label>
|
||||
<PasswordInput
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
onClick={() => handleLogin()}
|
||||
variant="solid"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
<Card.Footer justifyContent="flex-end">
|
||||
{triggerLogout && (
|
||||
<MyAlert
|
||||
status="success"
|
||||
title={t("logout-success")}
|
||||
description={t("logout-success-desc")}
|
||||
/>
|
||||
</Field.Root>
|
||||
</Stack>
|
||||
</Card.Body>
|
||||
<Card.Footer justifyContent="flex-end">
|
||||
{isError && (
|
||||
<MyAlert status="error" title={errorMsg} description={errorDsc} />
|
||||
)}
|
||||
<Button type="submit" onClick={() => handleLogin()} variant="solid">
|
||||
Login
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
<Card.Footer justifyContent="flex-end">
|
||||
{triggerLogout && (
|
||||
<MyAlert
|
||||
status="success"
|
||||
title={t("logout-success")}
|
||||
description={t("logout-success-desc")}
|
||||
/>
|
||||
)}
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</form>
|
||||
<Footer />
|
||||
</div>
|
||||
)}
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,6 +52,13 @@ export const MyLoansPage = () => {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 503) {
|
||||
setMsgStatus("error");
|
||||
setMsgTitle(t("serviceDeactivatedHeadline"));
|
||||
setMsgDescription(t("loan_page_serviceDeactivatedText"));
|
||||
setIsMsg(true);
|
||||
return;
|
||||
}
|
||||
setMsgStatus("error");
|
||||
setMsgTitle(t("error"));
|
||||
setMsgDescription(t("error-fetching-loans"));
|
||||
@@ -84,6 +91,14 @@ export const MyLoansPage = () => {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 507) {
|
||||
setMsgStatus("error");
|
||||
setMsgTitle(t("error"));
|
||||
setMsgDescription(t("error-deleting-loan-507"));
|
||||
setIsMsg(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setMsgStatus("error");
|
||||
setMsgTitle(t("error"));
|
||||
setMsgDescription(t("error-deleting-loan"));
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import Cookies from "js-cookie";
|
||||
import { API_BASE } from "@/config/api.config";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export const getBorrowableItems = async (
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/api/loans/borrowable-items`, {
|
||||
method: "POST",
|
||||
@@ -20,11 +17,22 @@ export const getBorrowableItems = async (
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 503) {
|
||||
return {
|
||||
data: null,
|
||||
status: "error",
|
||||
title: "Service deactivated",
|
||||
description:
|
||||
"The loan service is currently deactivated. Please try again later.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: null,
|
||||
status: "error",
|
||||
title: "Server error",
|
||||
description: t("serverError"),
|
||||
description:
|
||||
"An error occurred on the server. Sometimes reloading the page helps. Otherwise, please contact the administrator.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,6 +70,16 @@ export const createLoan = async (
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 503) {
|
||||
return {
|
||||
data: null,
|
||||
status: "error",
|
||||
title: "Service deactivated",
|
||||
description:
|
||||
"The loan service is currently deactivated. Please try again later.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: null,
|
||||
status: "error",
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
"admin-status": "Admin-Status",
|
||||
"first-name": "Vorname",
|
||||
"last-name": "Nachname",
|
||||
"app-title": "Ausleihsystem (demo)",
|
||||
"app-title": "Ausleihsystem",
|
||||
"last-borrowed-person": "Zuletzt ausgeliehen von",
|
||||
"currently-borrowed-by": "Derzeit ausgeliehen von",
|
||||
"back": "Zurückgehen",
|
||||
@@ -88,5 +88,16 @@
|
||||
"take-loan-success": "Ausleihe erfolgreich abgeholt",
|
||||
"return-loan-success": "Ausleihe erfolgreich zurückgegeben",
|
||||
"network-error": "Netzwerkfehler. Kontaktieren Sie den Administrator.",
|
||||
"contactPage_messageDescription": "Bitte geben Sie hier Ihre Nachricht ein. Der Systemadministrator (Theis Gaedigk) wird sich so schnell wie möglich bei Ihnen melden."
|
||||
"contactPage_messageDescription": "Bitte geben Sie hier Ihre Nachricht ein. Der Systemadministrator (Theis Gaedigk) wird sich so schnell wie möglich bei Ihnen melden.",
|
||||
"naas": "No-as-a-service",
|
||||
"try-naas": "Klick mich",
|
||||
"naas-error": "Fehler mit no-as-a-service",
|
||||
"naas-error-desc": "Ein Fehler ist beim Kommunizieren mit no-as-a-service aufgetreten.",
|
||||
"naas-header": "Eine gute Möglichkeit, nein zu sagen...",
|
||||
"error-deleting-loan-507": "Die Ausleihe kann nicht gelöscht werden, da sie noch nicht zurückgegeben wurde.",
|
||||
"serviceDeactivatedHeadline": "Service deaktiviert",
|
||||
"contactPage_serviceDeactivatedText": "Der Kontaktservice ist derzeit deaktiviert. Bitte versuchen Sie es später erneut.",
|
||||
"loan_page_serviceDeactivatedText": "Der Ausleihservice ist derzeit deaktiviert. Bitte versuchen Sie es später erneut.",
|
||||
"is-deactivated": "ist deaktiviert.",
|
||||
"deactivated-services": "Deaktivierte Services"
|
||||
}
|
||||
@@ -68,7 +68,7 @@
|
||||
"admin-status": "Admin status",
|
||||
"first-name": "First name",
|
||||
"last-name": "Last name",
|
||||
"app-title": "Borrow System (demo)",
|
||||
"app-title": "Borrow System",
|
||||
"last-borrowed-person": "Last borrowed by",
|
||||
"currently-borrowed-by": "Currently borrowed by",
|
||||
"back": "Go back",
|
||||
@@ -88,5 +88,16 @@
|
||||
"take-loan-success": "Loan taken successfully",
|
||||
"return-loan-success": "Loan returned successfully",
|
||||
"network-error": "Network error. Please contact the administrator.",
|
||||
"contactPage_messageDescription": "Please enter your message here. The system administrator (Theis Gaedigk) will get back to you as soon as possible."
|
||||
"contactPage_messageDescription": "Please enter your message here. The system administrator (Theis Gaedigk) will get back to you as soon as possible.",
|
||||
"naas": "No-as-a-service",
|
||||
"try-naas": "Click me",
|
||||
"naas-error": "Error with no-as-a-service",
|
||||
"naas-error-desc": "An error occurred while communicating with no-as-a-service.",
|
||||
"naas-header": "A good way to say no...",
|
||||
"error-deleting-loan-507": "The loan cannot be deleted because it has not been returned yet.",
|
||||
"serviceDeactivatedHeadline": "Service deactivated",
|
||||
"contactPage_serviceDeactivatedText": "The contact service is currently deactivated. Please try again later.",
|
||||
"loan_page_serviceDeactivatedText": "The loan service is currently deactivated. Please try again later.",
|
||||
"is-deactivated": "is deactivated.",
|
||||
"deactivated-services": "Deactivated services"
|
||||
}
|
||||
@@ -1,23 +1,16 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import svgr from "vite-plugin-svgr";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "node:path";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
|
||||
server: {
|
||||
host: "0.0.0.0",
|
||||
allowedHosts: ["insta.the1s.de"],
|
||||
port: 8101,
|
||||
watch: { usePolling: true },
|
||||
hmr: {
|
||||
host: "insta.the1s.de",
|
||||
port: 8101,
|
||||
protocol: "wss",
|
||||
port: 8001,
|
||||
watch: {
|
||||
usePolling: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
Copyright (c) 2026 Theis Gaedigk
|
||||
|
||||
All rights reserved.
|
||||
|
||||
This source code is not to be copied, modified, or distributed in any form
|
||||
without explicit written permission from the author.
|
||||
@@ -14,7 +14,7 @@ server {
|
||||
}
|
||||
|
||||
location /backend/ {
|
||||
proxy_pass http://demo_borrow_system-backend_v2:8102/;
|
||||
proxy_pass http://borrow_system-backend_v2:8004/;
|
||||
}
|
||||
|
||||
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"version": "v1.3.2 (dev)",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -47,4 +47,4 @@
|
||||
"vite": "^7.1.0",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import UserTable from "../components/UserTable";
|
||||
import ItemTable from "../components/ItemTable";
|
||||
import LoanTable from "../components/LoanTable";
|
||||
import APIKeyTable from "@/components/APIKeyTable";
|
||||
import ServerConfig from "@/components/ServerConfig";
|
||||
import { MoveLeft } from "lucide-react";
|
||||
|
||||
type DashboardProps = {
|
||||
@@ -44,6 +45,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
|
||||
viewSchliessfaecher={() => setActiveView("Schließfächer")}
|
||||
viewUser={() => setActiveView("User")}
|
||||
viewAPI={() => setActiveView("API")}
|
||||
viewConfig={() => setActiveView("Server Konfiguration")}
|
||||
/>
|
||||
<Box flex="1" display="flex" flexDirection="column">
|
||||
<Flex
|
||||
@@ -88,6 +90,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
|
||||
{activeView === "Ausleihen" && <LoanTable />}
|
||||
{activeView === "Gegenstände" && <ItemTable />}
|
||||
{activeView === "API" && <APIKeyTable />}
|
||||
{activeView === "Server Konfiguration" && <ServerConfig />}
|
||||
</Box>
|
||||
</Box>
|
||||
</Flex>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useState } from "react";
|
||||
import { loginFunc } from "@/utils/loginUser";
|
||||
import MyAlert from "../components/myChakra/MyAlert";
|
||||
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
|
||||
const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
|
||||
const [username, setUsername] = useState("");
|
||||
@@ -43,8 +44,7 @@ const Login: React.FC<{ onSuccess: () => void }> = ({ onSuccess }) => {
|
||||
</Field.Root>
|
||||
<Field.Root>
|
||||
<Field.Label>password</Field.Label>
|
||||
<Input
|
||||
type="password"
|
||||
<PasswordInput
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
|
||||
@@ -9,6 +9,7 @@ type SidebarProps = {
|
||||
viewSchliessfaecher: () => void;
|
||||
viewUser: () => void;
|
||||
viewAPI: () => void;
|
||||
viewConfig: () => void;
|
||||
};
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({
|
||||
@@ -16,6 +17,7 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||
viewGegenstaende,
|
||||
viewUser,
|
||||
viewAPI,
|
||||
viewConfig
|
||||
}) => {
|
||||
const [info, setInfo] = useState<any>(null);
|
||||
|
||||
@@ -83,6 +85,15 @@ const Sidebar: React.FC<SidebarProps> = ({
|
||||
>
|
||||
API Keys
|
||||
</Link>
|
||||
<Link
|
||||
px={3}
|
||||
py={2}
|
||||
rounded="md"
|
||||
_hover={{ bg: "gray.700", textDecoration: "none" }}
|
||||
onClick={viewConfig}
|
||||
>
|
||||
Server Konfiguration
|
||||
</Link>
|
||||
</VStack>
|
||||
|
||||
<Box mt="auto" pt={8} fontSize="xs" color="gray.500">
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Table,
|
||||
Spinner,
|
||||
Text,
|
||||
VStack,
|
||||
Heading,
|
||||
Switch,
|
||||
} from "@chakra-ui/react";
|
||||
import MyAlert from "./myChakra/MyAlert";
|
||||
import Cookies from "js-cookie";
|
||||
import { useState, useEffect } from "react";
|
||||
import { formatDateTime } from "@/utils/userFuncs";
|
||||
import { API_BASE } from "@/config/api.config";
|
||||
|
||||
type Items = {
|
||||
id: number;
|
||||
function_name: string;
|
||||
active: boolean;
|
||||
entry_created_at: string;
|
||||
updated_at: string | null;
|
||||
};
|
||||
|
||||
const ServerConfig: React.FC = () => {
|
||||
const [items, setItems] = useState<Items[]>([]);
|
||||
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [errorDsc, setErrorDsc] = useState("");
|
||||
const [isError, setIsError] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [reload, setReload] = useState(false);
|
||||
|
||||
const handleSwitchChange = async (id: number, newState: boolean) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/admin/server-config/update?functionName=${encodeURIComponent(
|
||||
items.find((item) => item.id === id)?.function_name || "",
|
||||
)}&active=${newState}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (response.ok) {
|
||||
setReload((prev) => !prev);
|
||||
setError(
|
||||
"success",
|
||||
"Status updated",
|
||||
"The function status was updated successfully.",
|
||||
);
|
||||
} else {
|
||||
setError(
|
||||
"error",
|
||||
"Failed to update status",
|
||||
"There is an error updating the function status.",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(
|
||||
"error",
|
||||
"Failed to update status",
|
||||
"There is an error updating the function status.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const setError = (
|
||||
status: "error" | "success",
|
||||
message: string,
|
||||
description: string,
|
||||
) => {
|
||||
setIsError(false);
|
||||
setErrorStatus(status);
|
||||
setErrorMessage(message);
|
||||
setErrorDsc(description);
|
||||
setIsError(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/api/admin/server-config/all`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${Cookies.get("token")}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const data = await response.json();
|
||||
return data.data;
|
||||
} catch (error) {
|
||||
setError("error", "Failed to fetch items", "There is an error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchData().then((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
setItems(data);
|
||||
}
|
||||
});
|
||||
}, [reload]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Heading marginBottom={4} size="2xl">
|
||||
Server Konfiguration
|
||||
</Heading>
|
||||
{isError && (
|
||||
<MyAlert
|
||||
status={errorStatus}
|
||||
description={errorDsc}
|
||||
title={errorMessage}
|
||||
/>
|
||||
)}
|
||||
{isLoading && (
|
||||
<VStack colorPalette="teal">
|
||||
<Spinner color="colorPalette.600" />
|
||||
<Text color="colorPalette.600">Loading...</Text>
|
||||
</VStack>
|
||||
)}
|
||||
|
||||
<Table.Root size="sm" striped w="100%" style={{ tableLayout: "auto" }}>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
|
||||
<strong>#</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Service Name</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Toggle</strong>
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>
|
||||
<strong>Eintrag erstellt am</strong>
|
||||
</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{items.map((item) => (
|
||||
<Table.Row key={item.id}>
|
||||
<Table.Cell whiteSpace="nowrap">{item.id}</Table.Cell>
|
||||
<Table.Cell fontFamily="mono">{item.function_name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Switch.Root
|
||||
checked={item.active}
|
||||
onCheckedChange={() =>
|
||||
handleSwitchChange(item.id, !item.active)
|
||||
}
|
||||
>
|
||||
<Switch.HiddenInput />
|
||||
<Switch.Control>
|
||||
<Switch.Thumb />
|
||||
</Switch.Control>
|
||||
<Switch.Label />
|
||||
</Switch.Root>
|
||||
</Table.Cell>
|
||||
<Table.Cell whiteSpace="nowrap">
|
||||
{formatDateTime(item.entry_created_at)}
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerConfig;
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ESNext",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
@@ -23,13 +24,10 @@
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
/* Chakra / Pfad Aliases */
|
||||
"baseUrl": ".",
|
||||
/* Path aliases */
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"backend-info": {
|
||||
"version": "v2.1 (demo)"
|
||||
"version": "v2.2 (dev)"
|
||||
},
|
||||
"frontend-info": {
|
||||
"version": "v2.1 (demo)"
|
||||
"version": "v2.2 (dev)"
|
||||
},
|
||||
"admin-panel-info": {
|
||||
"version": "v1.3.2 (demo)"
|
||||
"version": "v1.3.2 (dev)"
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "backendv2",
|
||||
"version": "1.0.0",
|
||||
"version": "v2.1.1 (dev)",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "backendv2",
|
||||
"version": "1.0.0",
|
||||
"version": "v2.1.1 (dev)",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.1",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"jose": "^6.0.12",
|
||||
"mysql2": "^3.14.3",
|
||||
"nodemailer": "^7.0.6"
|
||||
@@ -349,6 +350,24 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz",
|
||||
"integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/filelist": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
|
||||
@@ -527,6 +546,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "backendv2",
|
||||
"version": "1.0.0",
|
||||
"version": "v2.1.1 (dev)",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
@@ -15,8 +15,9 @@
|
||||
"dotenv": "^17.2.1",
|
||||
"ejs": "^3.1.10",
|
||||
"express": "^5.1.0",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"jose": "^6.0.12",
|
||||
"mysql2": "^3.14.3",
|
||||
"nodemailer": "^7.0.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import mysql from "mysql2";
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
const pool = mysql
|
||||
.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
})
|
||||
.promise();
|
||||
|
||||
export const getAllFunctions = async () => {
|
||||
const [rows] = await pool.query("SELECT * FROM functions");
|
||||
return { success: true, data: rows };
|
||||
};
|
||||
|
||||
export const updateFunctionStatus = async (functionName, active) => {
|
||||
const [result] = await pool.query(
|
||||
"UPDATE functions SET active = ? WHERE function_name = ?",
|
||||
[active, functionName],
|
||||
);
|
||||
if (result.affectedRows > 0) return { success: true };
|
||||
return { success: false };
|
||||
};
|
||||
@@ -29,14 +29,14 @@ export const createUser = async (
|
||||
};
|
||||
|
||||
export const deleteUserById = async (userId) => {
|
||||
const [result] = await pool.query("DELETE FROM users WHERE id = ? AND secret_user = false", [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 (username, newPassword) => {
|
||||
const [result] = await pool.query(
|
||||
"UPDATE users SET password = ?, entry_updated_at = NOW() WHERE username = ? AND secret_user = false",
|
||||
"UPDATE users SET password = ?, entry_updated_at = NOW() WHERE username = ?",
|
||||
[newPassword, username],
|
||||
);
|
||||
if (result.affectedRows > 0) return { success: true };
|
||||
@@ -52,7 +52,7 @@ export const editUserById = async (
|
||||
is_admin,
|
||||
) => {
|
||||
const [result] = await pool.query(
|
||||
"UPDATE users SET first_name = ?, last_name = ?, role = ?, email = ?, is_admin = ?, entry_updated_at = NOW() WHERE id = ? AND secret_user = false",
|
||||
"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 };
|
||||
@@ -61,7 +61,7 @@ export const editUserById = async (
|
||||
|
||||
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 WHERE secret_user = false",
|
||||
"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 };
|
||||
@@ -69,7 +69,7 @@ export const getAllUsers = async () => {
|
||||
|
||||
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 = ? AND secret_user = false",
|
||||
"SELECT id, username, first_name, last_name, role, email, is_admin FROM users WHERE id = ?",
|
||||
[userId],
|
||||
);
|
||||
if (rows.length === 0) {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import express from "express";
|
||||
import { authenticateAdmin } from "../../services/authentication.js";
|
||||
const router = express.Router();
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
// database funcs import
|
||||
import {
|
||||
getAllFunctions,
|
||||
updateFunctionStatus,
|
||||
} from "./database/serverConfMgmt.database.js";
|
||||
|
||||
// Route to get all functions and their statuses
|
||||
router.get("/all", async (req, res) => {
|
||||
try {
|
||||
const result = await getAllFunctions();
|
||||
if (result.success) {
|
||||
res.status(200).json({ data: result.data });
|
||||
} else {
|
||||
res.status(500).json({ message: "Failed to fetch functions" });
|
||||
}
|
||||
} catch (error) {
|
||||
res
|
||||
.status(500)
|
||||
.json({ message: "An error occurred", error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Route to update the status of a function
|
||||
router.post("/update", async (req, res) => {
|
||||
const functionName = req.query.functionName;
|
||||
let active = req.query.active;
|
||||
|
||||
if (active === "false") {
|
||||
active = 0;
|
||||
} else if (active === "true") {
|
||||
active = 1;
|
||||
} else {
|
||||
res.status(406).json({ message: "Got unexpected format" });
|
||||
}
|
||||
|
||||
const result = await updateFunctionStatus(functionName, active);
|
||||
if (result.success) {
|
||||
res.status(200).json({ message: "Function status updated successfully" });
|
||||
} else {
|
||||
res.status(500).json({ message: "Failed to update function status" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -22,7 +22,7 @@ export const getItemsFromDatabaseV2 = async () => {
|
||||
export const getLoanByCodeV2 = async (loan_code) => {
|
||||
const [result] = await pool.query(
|
||||
"SELECT username, returned_date, take_date, lockers FROM loans WHERE loan_code = ?;",
|
||||
[loan_code]
|
||||
[loan_code],
|
||||
);
|
||||
if (result.length > 0) {
|
||||
return { success: true, data: result[0] };
|
||||
@@ -33,7 +33,7 @@ export const getLoanByCodeV2 = async (loan_code) => {
|
||||
export const changeInSafeStateV2 = async (itemId) => {
|
||||
const [result] = await pool.query(
|
||||
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
|
||||
[itemId]
|
||||
[itemId],
|
||||
);
|
||||
if (result.affectedRows > 0) {
|
||||
return { success: true };
|
||||
@@ -42,50 +42,62 @@ export const changeInSafeStateV2 = async (itemId) => {
|
||||
};
|
||||
|
||||
export const setReturnDateV2 = async (loanCode) => {
|
||||
const [items] = await pool.query(
|
||||
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
|
||||
[loanCode]
|
||||
);
|
||||
try {
|
||||
const [items] = await pool.query(
|
||||
"SELECT loaned_items_id, username FROM loans WHERE loan_code = ?",
|
||||
[loanCode],
|
||||
);
|
||||
|
||||
const [owner] = await pool.query(
|
||||
"SELECT username FROM loans WHERE loan_code = ?",
|
||||
[loanCode]
|
||||
);
|
||||
if (items.length === 0)
|
||||
return { success: false, message: "No items found for loan" };
|
||||
|
||||
if (items.length === 0) return { success: false };
|
||||
const itemIds = Array.isArray(items[0].loaned_items_id)
|
||||
? items[0].loaned_items_id
|
||||
: JSON.parse(items[0].loaned_items_id || "[]");
|
||||
|
||||
const itemIds = Array.isArray(items[0].loaned_items_id)
|
||||
? items[0].loaned_items_id
|
||||
: JSON.parse(items[0].loaned_items_id || "[]");
|
||||
const [result] = await pool.query(
|
||||
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ? AND returned_date IS NULL",
|
||||
[loanCode],
|
||||
);
|
||||
|
||||
const [setItemStates] = await pool.query(
|
||||
"UPDATE items SET in_safe = 1, currently_borrowing = NULL, last_borrowed_person = (?) WHERE id IN (?)",
|
||||
[owner[0].username, itemIds]
|
||||
);
|
||||
if (result.affectedRows === 0) return { success: false };
|
||||
|
||||
const [result] = await pool.query(
|
||||
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
|
||||
[loanCode]
|
||||
);
|
||||
if (itemIds.length > 0) {
|
||||
await pool.query(
|
||||
"UPDATE items SET in_safe = 1, currently_borrowing = NULL, last_borrowed_person = ? WHERE id IN (?)",
|
||||
[items[0].username, itemIds],
|
||||
);
|
||||
}
|
||||
|
||||
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
||||
return { success: true };
|
||||
return { success: true, data: { returned: true } };
|
||||
} catch (error) {
|
||||
console.error("setReturnDateV2 error:", error);
|
||||
return { success: false, message: "Failed to set return date" };
|
||||
}
|
||||
return { success: false };
|
||||
};
|
||||
|
||||
export const setTakeDateV2 = async (loanCode) => {
|
||||
const [isTaken] = await pool.query(
|
||||
"SELECT take_date FROM loans WHERE loan_code = ?",
|
||||
[loanCode],
|
||||
);
|
||||
|
||||
if (isTaken.length === 0 || isTaken[0].take_date !== null) {
|
||||
return { success: false, message: "Loan not found or already taken" };
|
||||
}
|
||||
|
||||
const [items] = await pool.query(
|
||||
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
|
||||
[loanCode]
|
||||
[loanCode],
|
||||
);
|
||||
|
||||
const [owner] = await pool.query(
|
||||
"SELECT username FROM loans WHERE loan_code = ?",
|
||||
[loanCode]
|
||||
[loanCode],
|
||||
);
|
||||
|
||||
if (items.length === 0) return { success: false };
|
||||
if (items.length === 0)
|
||||
return { success: false, message: "No items found for loan" };
|
||||
|
||||
const itemIds = Array.isArray(items[0].loaned_items_id)
|
||||
? items[0].loaned_items_id
|
||||
@@ -93,18 +105,18 @@ export const setTakeDateV2 = async (loanCode) => {
|
||||
|
||||
const [setItemStates] = await pool.query(
|
||||
"UPDATE items SET in_safe = 0, currently_borrowing = (?) WHERE id IN (?)",
|
||||
[owner[0].username, itemIds]
|
||||
[owner[0].username, itemIds],
|
||||
);
|
||||
|
||||
const [result] = await pool.query(
|
||||
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
|
||||
[loanCode]
|
||||
"UPDATE loans SET take_date = NOW() WHERE loan_code = ? AND take_date IS NULL",
|
||||
[loanCode],
|
||||
);
|
||||
|
||||
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false };
|
||||
return { message: "Failed to set take date", success: false };
|
||||
};
|
||||
|
||||
export const getAllLoansV2 = async () => {
|
||||
@@ -118,12 +130,12 @@ export const getAllLoansV2 = async () => {
|
||||
export const openDoor = async (doorKey) => {
|
||||
const [result] = await pool.query(
|
||||
"SELECT safe_nr, id FROM items WHERE door_key = ?;",
|
||||
[doorKey]
|
||||
[doorKey],
|
||||
);
|
||||
if (result.length > 0) {
|
||||
const [changeItemSate] = await pool.query(
|
||||
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
|
||||
[result[0].id]
|
||||
[result[0].id],
|
||||
);
|
||||
if (changeItemSate.affectedRows > 0) {
|
||||
return { success: true, data: result[0] };
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import express from "express";
|
||||
import { authenticate } from "../../services/authentication.js";
|
||||
import { checkIfServiceIsActive } from "../../services/functions.js";
|
||||
const router = express.Router();
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
const loan_service = "Loan Service";
|
||||
|
||||
import {
|
||||
getItemsFromDatabaseV2,
|
||||
changeInSafeStateV2,
|
||||
@@ -39,6 +42,7 @@ router.post("/change-state/:key/:itemId", authenticate, async (req, res) => {
|
||||
router.get(
|
||||
"/get-loan-by-code/:key/:loan_code",
|
||||
authenticate,
|
||||
checkIfServiceIsActive(loan_service),
|
||||
async (req, res) => {
|
||||
const loan_code = req.params.loan_code;
|
||||
const result = await getLoanByCodeV2(loan_code);
|
||||
@@ -47,37 +51,39 @@ router.get(
|
||||
} else {
|
||||
res.status(404).json({ message: "Loan not found" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Route for API to set the return date by the loan code
|
||||
router.post(
|
||||
"/set-return-date/:key/:loan_code",
|
||||
authenticate,
|
||||
checkIfServiceIsActive(loan_service),
|
||||
async (req, res) => {
|
||||
const loanCode = req.params.loan_code;
|
||||
const result = await setReturnDateV2(loanCode);
|
||||
if (result.success) {
|
||||
res.status(200).json({ data: result.data });
|
||||
res.status(200).json({});
|
||||
} else {
|
||||
res.status(500).json({ message: "Failed to set return date" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Route for API to set the take away date by the loan code
|
||||
router.post(
|
||||
"/set-take-date/:key/:loan_code",
|
||||
authenticate,
|
||||
checkIfServiceIsActive(loan_service),
|
||||
async (req, res) => {
|
||||
const loanCode = req.params.loan_code;
|
||||
const result = await setTakeDateV2(loanCode);
|
||||
if (result.success) {
|
||||
res.status(200).json({ data: result.data });
|
||||
res.status(200).json({});
|
||||
} else {
|
||||
res.status(500).json({ message: "Failed to set take date" });
|
||||
res.status(500).json({ message: result.message });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Route for API to open a door
|
||||
|
||||
@@ -234,6 +234,23 @@ export const getBorrowableItemsFromDatabase = async (
|
||||
};
|
||||
|
||||
export const SETdeleteLoanFromDatabase = async (loanId) => {
|
||||
const [checkIfdatesReturned] = await pool.query(
|
||||
"SELECT take_date, returned_date FROM loans WHERE id = ? AND deleted = 0",
|
||||
[loanId],
|
||||
);
|
||||
|
||||
if (checkIfdatesReturned.length === 0) {
|
||||
return { success: false, code: "LOAN_NOT_FOUND" };
|
||||
}
|
||||
|
||||
const { take_date, returned_date } = checkIfdatesReturned[0];
|
||||
const bothNull = take_date === null && returned_date === null;
|
||||
const bothSet = take_date !== null && returned_date !== null;
|
||||
|
||||
if (!(bothNull || bothSet)) {
|
||||
return { success: false, code: "LOAN_NOT_RETURNED" };
|
||||
}
|
||||
|
||||
const [result] = await pool.query(
|
||||
"UPDATE loans SET deleted = 1 WHERE id = ?;",
|
||||
[loanId],
|
||||
|
||||
@@ -14,7 +14,7 @@ const pool = mysql
|
||||
export const loginFunc = async (username, password) => {
|
||||
const [result] = await pool.query(
|
||||
"SELECT * FROM users WHERE username = ? AND password = ?",
|
||||
[username, password]
|
||||
[username, password],
|
||||
);
|
||||
if (result.length > 0) return { success: true, data: result[0] };
|
||||
return { success: false };
|
||||
@@ -40,7 +40,7 @@ export const changePassword = async (username, oldPassword, newPassword) => {
|
||||
// get user current password
|
||||
const [user] = await pool.query(
|
||||
"SELECT * FROM users WHERE username = ? AND password = ?",
|
||||
[username, oldPassword]
|
||||
[username, oldPassword],
|
||||
);
|
||||
if (user.length === 0) return { success: false };
|
||||
|
||||
@@ -48,8 +48,16 @@ export const changePassword = async (username, oldPassword, newPassword) => {
|
||||
|
||||
const [result] = await pool.query(
|
||||
"UPDATE users SET password = ? WHERE username = ?",
|
||||
[newPassword, username]
|
||||
[newPassword, username],
|
||||
);
|
||||
if (result.affectedRows > 0) return { success: true };
|
||||
return { success: false };
|
||||
};
|
||||
|
||||
export const getDeactivatedServices = async () => {
|
||||
const [rows] = await pool.query("SELECT function_name FROM functions WHERE active = 0;");
|
||||
if (rows.length > 0) {
|
||||
return { success: true, data: rows };
|
||||
}
|
||||
return { success: false };
|
||||
};
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import express from "express";
|
||||
import { authenticate, generateToken } from "../../services/authentication.js";
|
||||
import {
|
||||
checkIfServiceIsActive,
|
||||
checkIfServiceIsActive2,
|
||||
} from "../../services/functions.js";
|
||||
const router = express.Router();
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
const loan_service = "Loan Service";
|
||||
const loan_mailer_service = "Loan Mailer";
|
||||
|
||||
// database funcs import
|
||||
import {
|
||||
createLoanInDatabase,
|
||||
@@ -18,105 +25,129 @@ import {
|
||||
} from "./database/loansMgmt.database.js";
|
||||
import { sendMailLoan } from "./services/mailer.js";
|
||||
|
||||
router.post("/createLoan", authenticate, async (req, res) => {
|
||||
try {
|
||||
const { items, startDate, endDate, note } = req.body || {};
|
||||
router.post(
|
||||
"/createLoan",
|
||||
checkIfServiceIsActive(loan_service),
|
||||
authenticate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { items, startDate, endDate, note } = req.body || {};
|
||||
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ message: "Items array is required" });
|
||||
}
|
||||
if (!Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ message: "Items array is required" });
|
||||
}
|
||||
|
||||
// If dates are not provided, default to now .. +7 days
|
||||
const start =
|
||||
startDate ?? new Date().toISOString().slice(0, 19).replace("T", " ");
|
||||
const end =
|
||||
endDate ??
|
||||
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 19)
|
||||
.replace("T", " ");
|
||||
// If dates are not provided, default to now .. +7 days
|
||||
const start =
|
||||
startDate ?? new Date().toISOString().slice(0, 19).replace("T", " ");
|
||||
const end =
|
||||
endDate ??
|
||||
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 19)
|
||||
.replace("T", " ");
|
||||
|
||||
// Coerce item IDs to numbers and filter invalids
|
||||
const itemIds = items
|
||||
.map((v) => Number(v))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
// Coerce item IDs to numbers and filter invalids
|
||||
const itemIds = items
|
||||
.map((v) => Number(v))
|
||||
.filter((n) => Number.isFinite(n));
|
||||
|
||||
if (itemIds.length === 0) {
|
||||
return res.status(400).json({ message: "No valid item IDs provided" });
|
||||
}
|
||||
if (itemIds.length === 0) {
|
||||
return res.status(400).json({ message: "No valid item IDs provided" });
|
||||
}
|
||||
|
||||
const result = await createLoanInDatabase(
|
||||
req.user.username,
|
||||
start,
|
||||
end,
|
||||
note,
|
||||
itemIds,
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
const mailInfo = await getLoanInfoWithID(result.data.id);
|
||||
console.log(mailInfo);
|
||||
sendMailLoan(
|
||||
mailInfo.data.username,
|
||||
mailInfo.data.loaned_items_name,
|
||||
mailInfo.data.start_date,
|
||||
mailInfo.data.end_date,
|
||||
mailInfo.data.created_at,
|
||||
const result = await createLoanInDatabase(
|
||||
req.user.username,
|
||||
start,
|
||||
end,
|
||||
note,
|
||||
itemIds,
|
||||
);
|
||||
return res.status(201).json({
|
||||
message: "Loan created successfully",
|
||||
loanId: result.data.id,
|
||||
loanCode: result.data.loan_code,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
if (await checkIfServiceIsActive2(loan_mailer_service)) {
|
||||
const mailInfo = await getLoanInfoWithID(result.data.id);
|
||||
console.log(mailInfo);
|
||||
sendMailLoan(
|
||||
mailInfo.data.username,
|
||||
mailInfo.data.loaned_items_name,
|
||||
mailInfo.data.start_date,
|
||||
mailInfo.data.end_date,
|
||||
mailInfo.data.created_at,
|
||||
mailInfo.data.note,
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
message: "Loan created successfully",
|
||||
loanId: result.data.id,
|
||||
loanCode: result.data.loan_code,
|
||||
});
|
||||
}
|
||||
|
||||
if (result.code === "CONFLICT") {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ message: "Items not available in the selected period" });
|
||||
}
|
||||
|
||||
if (result.code === "BAD_REQUEST") {
|
||||
return res.status(400).json({ message: result.message });
|
||||
}
|
||||
|
||||
return res.status(500).json({ message: "Failed to create loan" });
|
||||
} catch (err) {
|
||||
console.error("createLoan error:", err);
|
||||
return res.status(500).json({ message: "Failed to create loan" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (result.code === "CONFLICT") {
|
||||
return res
|
||||
.status(409)
|
||||
.json({ message: "Items not available in the selected period" });
|
||||
router.get(
|
||||
"/loans",
|
||||
checkIfServiceIsActive(loan_service),
|
||||
authenticate,
|
||||
async (req, res) => {
|
||||
const result = await getLoansFromDatabase(req.user.username);
|
||||
if (result.success) {
|
||||
res.status(200).json(result.data);
|
||||
} else if (result.status) {
|
||||
res.status(200).json([]);
|
||||
} else {
|
||||
res.status(500).json({ message: "Failed to fetch loans" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (result.code === "BAD_REQUEST") {
|
||||
return res.status(400).json({ message: result.message });
|
||||
router.post(
|
||||
"/set-return-date/:loan_code",
|
||||
checkIfServiceIsActive(loan_service),
|
||||
authenticate,
|
||||
async (req, res) => {
|
||||
const loanCode = req.params.loan_code;
|
||||
const result = await setReturnDate(loanCode);
|
||||
if (result.success) {
|
||||
res.status(200).json({ data: result.data });
|
||||
} else {
|
||||
res.status(500).json({ message: "Failed to set return date" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return res.status(500).json({ message: "Failed to create loan" });
|
||||
} catch (err) {
|
||||
console.error("createLoan error:", err);
|
||||
return res.status(500).json({ message: "Failed to create loan" });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/loans", authenticate, async (req, res) => {
|
||||
const result = await getLoansFromDatabase(req.user.username);
|
||||
if (result.success) {
|
||||
res.status(200).json(result.data);
|
||||
} else if (result.status) {
|
||||
res.status(200).json([]);
|
||||
} else {
|
||||
res.status(500).json({ message: "Failed to fetch loans" });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/set-return-date/:loan_code", authenticate, async (req, res) => {
|
||||
const loanCode = req.params.loan_code;
|
||||
const result = await setReturnDate(loanCode);
|
||||
if (result.success) {
|
||||
res.status(200).json({ data: result.data });
|
||||
} else {
|
||||
res.status(500).json({ message: "Failed to set return date" });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/set-take-date/:loan_code", authenticate, async (req, res) => {
|
||||
const loanCode = req.params.loan_code;
|
||||
const result = await setTakeDate(loanCode);
|
||||
if (result.success) {
|
||||
res.status(200).json({ data: result.data });
|
||||
} else {
|
||||
res.status(500).json({ message: "Failed to set take date" });
|
||||
}
|
||||
});
|
||||
router.post(
|
||||
"/set-take-date/:loan_code",
|
||||
checkIfServiceIsActive(loan_service),
|
||||
authenticate,
|
||||
async (req, res) => {
|
||||
const loanCode = req.params.loan_code;
|
||||
const result = await setTakeDate(loanCode);
|
||||
if (result.success) {
|
||||
res.status(200).json({ data: result.data });
|
||||
} else {
|
||||
res.status(500).json({ message: "Failed to set take date" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/all-items", authenticate, async (req, res) => {
|
||||
const result = await getItems();
|
||||
@@ -127,46 +158,71 @@ router.get("/all-items", authenticate, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/delete-loan/:id", authenticate, async (req, res) => {
|
||||
const loanId = req.params.id;
|
||||
const result = await SETdeleteLoanFromDatabase(loanId);
|
||||
if (result.success) {
|
||||
res.status(200).json({ message: "Loan deleted successfully" });
|
||||
} else {
|
||||
res.status(500).json({ message: "Failed to delete loan" });
|
||||
}
|
||||
});
|
||||
router.delete(
|
||||
"/delete-loan/:id",
|
||||
checkIfServiceIsActive(loan_service),
|
||||
authenticate,
|
||||
async (req, res) => {
|
||||
const loanId = req.params.id;
|
||||
const result = await SETdeleteLoanFromDatabase(loanId);
|
||||
if (result.success) {
|
||||
res.status(200).json({ message: "Loan deleted successfully" });
|
||||
} else {
|
||||
if (result.code === "LOAN_NOT_FOUND") {
|
||||
res.status(404).json({ message: "Loan not found" });
|
||||
}
|
||||
|
||||
router.get("/all-loans", authenticate, async (req, res) => {
|
||||
const result = await getALLLoans();
|
||||
if (result.success) {
|
||||
res.status(200).json(result.data);
|
||||
} else {
|
||||
res.status(500).json({ message: "Failed to fetch loans" });
|
||||
}
|
||||
});
|
||||
if (result.code === "LOAN_NOT_RETURNED") {
|
||||
res.status(507).json({
|
||||
message: "Cannot delete loan that has not been returned",
|
||||
});
|
||||
}
|
||||
|
||||
router.post("/borrowable-items", authenticate, async (req, res) => {
|
||||
const { startDate, endDate } = req.body || {};
|
||||
if (!startDate || !endDate) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "startDate and endDate are required" });
|
||||
}
|
||||
res.status(500).json({ message: "Failed to delete loan" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const result = await getBorrowableItemsFromDatabase(
|
||||
startDate,
|
||||
endDate,
|
||||
req.user.role,
|
||||
);
|
||||
if (result.success) {
|
||||
// return the array directly for consistency with /items
|
||||
return res.status(200).json(result.data);
|
||||
} else {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Failed to fetch borrowable items" });
|
||||
}
|
||||
});
|
||||
router.get(
|
||||
"/all-loans",
|
||||
checkIfServiceIsActive(loan_service),
|
||||
authenticate,
|
||||
async (req, res) => {
|
||||
const result = await getALLLoans();
|
||||
if (result.success) {
|
||||
res.status(200).json(result.data);
|
||||
} else {
|
||||
res.status(500).json({ message: "Failed to fetch loans" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/borrowable-items",
|
||||
checkIfServiceIsActive(loan_service),
|
||||
authenticate,
|
||||
async (req, res) => {
|
||||
const { startDate, endDate } = req.body || {};
|
||||
if (!startDate || !endDate) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: "startDate and endDate are required" });
|
||||
}
|
||||
|
||||
const result = await getBorrowableItemsFromDatabase(
|
||||
startDate,
|
||||
endDate,
|
||||
req.user.role,
|
||||
);
|
||||
if (result.success) {
|
||||
// return the array directly for consistency with /items
|
||||
return res.status(200).json(result.data);
|
||||
} else {
|
||||
return res
|
||||
.status(500)
|
||||
.json({ message: "Failed to fetch borrowable items" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -34,7 +34,14 @@ const formatDateTime = (value) => {
|
||||
return "N/A";
|
||||
};
|
||||
|
||||
function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
|
||||
function buildLoanEmail({
|
||||
user,
|
||||
items,
|
||||
startDate,
|
||||
endDate,
|
||||
createdDate,
|
||||
note,
|
||||
}) {
|
||||
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
|
||||
const itemsList =
|
||||
Array.isArray(items) && items.length
|
||||
@@ -116,6 +123,12 @@ function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
|
||||
createdDate,
|
||||
)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px; color:#6b7280; vertical-align:top;">Notiz</td>
|
||||
<td style="padding:10px 14px; font-weight:600; color:#111827;">${
|
||||
note || "Keine Notiz"
|
||||
}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="margin:22px 0 0 0; font-size:14px;">
|
||||
@@ -134,7 +147,14 @@ function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
|
||||
function buildLoanEmailText({
|
||||
user,
|
||||
items,
|
||||
startDate,
|
||||
endDate,
|
||||
createdDate,
|
||||
note,
|
||||
}) {
|
||||
const itemsText =
|
||||
Array.isArray(items) && items.length ? items.join(", ") : "N/A";
|
||||
return [
|
||||
@@ -145,10 +165,18 @@ function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
|
||||
`Start: ${formatDateTime(startDate)}`,
|
||||
`Ende: ${formatDateTime(endDate)}`,
|
||||
`Erstellt am: ${formatDateTime(createdDate)}`,
|
||||
`Notiz: ${note || "Keine Notiz"}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function sendMailLoan(user, items, startDate, endDate, createdDate) {
|
||||
export function sendMailLoan(
|
||||
user,
|
||||
items,
|
||||
startDate,
|
||||
endDate,
|
||||
createdDate,
|
||||
note,
|
||||
) {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.MAIL_HOST,
|
||||
port: process.env.MAIL_PORT,
|
||||
@@ -170,8 +198,16 @@ export function sendMailLoan(user, items, startDate, endDate, createdDate) {
|
||||
startDate,
|
||||
endDate,
|
||||
createdDate,
|
||||
note,
|
||||
}),
|
||||
html: buildLoanEmail({
|
||||
user,
|
||||
items,
|
||||
startDate,
|
||||
endDate,
|
||||
createdDate,
|
||||
note,
|
||||
}),
|
||||
html: buildLoanEmail({ user, items, startDate, endDate, createdDate }),
|
||||
});
|
||||
|
||||
console.log("Loan message sent:", info.messageId);
|
||||
|
||||
@@ -1,48 +1,79 @@
|
||||
import express from "express";
|
||||
import { authenticate, generateToken } from "../../services/authentication.js";
|
||||
import { checkIfServiceIsActive } from "../../services/functions.js";
|
||||
const router = express.Router();
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
const user_frontend_service = "User Frontend";
|
||||
const contact_form_service = "Contact Form Service";
|
||||
|
||||
// database funcs import
|
||||
import { loginFunc, changePassword } from "./database/userMgmt.database.js";
|
||||
import {
|
||||
loginFunc,
|
||||
changePassword,
|
||||
getDeactivatedServices,
|
||||
} from "./database/userMgmt.database.js";
|
||||
import { sendMail } from "./services/mailer_v2.js";
|
||||
|
||||
router.post("/login", async (req, res) => {
|
||||
const result = await loginFunc(req.body.username, req.body.password);
|
||||
router.post(
|
||||
"/login",
|
||||
checkIfServiceIsActive(user_frontend_service),
|
||||
async (req, res) => {
|
||||
const result = await loginFunc(req.body.username, req.body.password);
|
||||
if (result.success) {
|
||||
const token = await generateToken({
|
||||
username: result.data.username,
|
||||
is_admin: result.data.is_admin,
|
||||
first_name: result.data.first_name,
|
||||
last_name: result.data.last_name,
|
||||
role: result.data.role,
|
||||
});
|
||||
res.status(200).json({ message: "Login successful", token });
|
||||
} else {
|
||||
res.status(401).json({ message: "Invalid credentials" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/change-password",
|
||||
checkIfServiceIsActive(user_frontend_service),
|
||||
authenticate,
|
||||
async (req, res) => {
|
||||
const oldPassword = req.body.oldPassword;
|
||||
const newPassword = req.body.newPassword;
|
||||
const username = req.user.username;
|
||||
const result = await changePassword(username, oldPassword, newPassword);
|
||||
if (result.success) {
|
||||
res.status(200).json({ message: "Password changed successfully" });
|
||||
} else {
|
||||
res.status(500).json({ message: "Failed to change password" });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
"/contact",
|
||||
checkIfServiceIsActive(contact_form_service),
|
||||
authenticate,
|
||||
async (req, res) => {
|
||||
const message = req.body.message;
|
||||
const username = req.user.username;
|
||||
|
||||
sendMail(username, message);
|
||||
|
||||
res.status(200).json({ message: "Contact message sent successfully" });
|
||||
},
|
||||
);
|
||||
|
||||
router.get("/deactivated-services", authenticate, async (req, res) => {
|
||||
const result = await getDeactivatedServices();
|
||||
if (result.success) {
|
||||
const token = await generateToken({
|
||||
username: result.data.username,
|
||||
is_admin: result.data.is_admin,
|
||||
first_name: result.data.first_name,
|
||||
last_name: result.data.last_name,
|
||||
role: result.data.role,
|
||||
});
|
||||
res.status(200).json({ message: "Login successful", token });
|
||||
res.status(200).json(result.data);
|
||||
} else {
|
||||
res.status(401).json({ message: "Invalid credentials" });
|
||||
res.status(500).json({ message: "Failed to fetch deactivated services" });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/change-password", authenticate, async (req, res) => {
|
||||
const oldPassword = req.body.oldPassword;
|
||||
const newPassword = req.body.newPassword;
|
||||
const username = req.user.username;
|
||||
const result = await changePassword(username, oldPassword, newPassword);
|
||||
if (result.success) {
|
||||
res.status(200).json({ message: "Password changed successfully" });
|
||||
} else {
|
||||
res.status(500).json({ message: "Failed to change password" });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/contact", authenticate, async (req, res) => {
|
||||
const message = req.body.message;
|
||||
const username = req.user.username;
|
||||
|
||||
sendMail(username, message);
|
||||
|
||||
res.status(200).json({ message: "Contact message sent successfully" });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
USE borrow_system_new;
|
||||
|
||||
-- USERS
|
||||
INSERT INTO users (username, password, email, first_name, last_name, role, is_admin)
|
||||
VALUES
|
||||
('user1', 'passwordhash1', 'user1@example.com', 'First1', 'Last1', 1, false),
|
||||
('user2', 'passwordhash2', 'user2@example.com', 'First2', 'Last2', 1, false),
|
||||
('user3', 'passwordhash3', 'user3@example.com', 'First3', 'Last3', 2, false),
|
||||
('admin1', 'passwordhash4', 'admin1@example.com', 'Admin', 'One', 9, true),
|
||||
('admin2', 'passwordhash5', 'admin2@example.com', 'Admin', 'Two', 9, true);
|
||||
|
||||
-- ITEMS
|
||||
INSERT INTO items (item_name, can_borrow_role, in_safe, safe_nr, door_key, last_borrowed_person, currently_borrowing)
|
||||
VALUES
|
||||
('Item1', 1, true, 1, 101, NULL, NULL),
|
||||
('Item2', 1, true, 2, 102, 'user1', 'user1'),
|
||||
('Item3', 2, true, 3, 103, 'user2', NULL),
|
||||
('Item4', 1, false, NULL, NULL, NULL, NULL),
|
||||
('Item5', 2, false, NULL, NULL, 'user3', 'user3');
|
||||
|
||||
-- LOANS
|
||||
INSERT INTO loans (
|
||||
username,
|
||||
lockers,
|
||||
loan_code,
|
||||
start_date,
|
||||
end_date,
|
||||
take_date,
|
||||
returned_date,
|
||||
created_at,
|
||||
loaned_items_id,
|
||||
loaned_items_name,
|
||||
deleted,
|
||||
note
|
||||
)
|
||||
VALUES
|
||||
(
|
||||
'user1',
|
||||
JSON_ARRAY('Locker1', 'Locker2'),
|
||||
'123456',
|
||||
'2026-02-01 09:00:00',
|
||||
'2026-02-10 17:00:00',
|
||||
'2026-02-01 09:15:00',
|
||||
NULL,
|
||||
'2026-02-01 09:00:00',
|
||||
JSON_ARRAY(1, 2),
|
||||
JSON_ARRAY('Item1', 'Item2'),
|
||||
false,
|
||||
'Erste allgemeine Ausleihe'
|
||||
),
|
||||
(
|
||||
'user2',
|
||||
JSON_ARRAY('Locker3'),
|
||||
'234567',
|
||||
'2026-02-02 10:00:00',
|
||||
'2026-02-05 16:00:00',
|
||||
'2026-02-02 10:05:00',
|
||||
'2026-02-05 15:30:00',
|
||||
'2026-02-02 10:00:00',
|
||||
JSON_ARRAY(3),
|
||||
JSON_ARRAY('Item3'),
|
||||
false,
|
||||
'Zurückgegeben vor Enddatum'
|
||||
),
|
||||
(
|
||||
'user3',
|
||||
JSON_ARRAY(),
|
||||
'345678',
|
||||
'2026-02-03 08:30:00',
|
||||
'2026-02-15 18:00:00',
|
||||
NULL,
|
||||
NULL,
|
||||
'2026-02-03 08:30:00',
|
||||
JSON_ARRAY(5),
|
||||
JSON_ARRAY('Item5'),
|
||||
false,
|
||||
'Noch ausgeliehen'
|
||||
),
|
||||
(
|
||||
'user1',
|
||||
JSON_ARRAY('Locker4'),
|
||||
'456789',
|
||||
'2025-12-01 09:00:00',
|
||||
'2025-12-03 17:00:00',
|
||||
'2025-12-01 09:10:00',
|
||||
'2025-12-03 16:45:00',
|
||||
'2025-12-01 09:00:00',
|
||||
JSON_ARRAY(1),
|
||||
JSON_ARRAY('Item1'),
|
||||
true,
|
||||
'Alte, gelöschte Ausleihe'
|
||||
);
|
||||
|
||||
-- API KEYS
|
||||
INSERT INTO apiKeys (api_key, entry_name)
|
||||
VALUES
|
||||
('10000001', 'Entry1'),
|
||||
('10000002', 'Entry2'),
|
||||
('10000003', 'Entry3'),
|
||||
('10000004', 'Entry4');
|
||||
@@ -11,7 +11,6 @@ CREATE TABLE users (
|
||||
is_admin bool NOT NULL DEFAULT false,
|
||||
entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
secret_user bool NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
@@ -55,4 +54,15 @@ CREATE TABLE apiKeys (
|
||||
entry_created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
CHECK (api_key REGEXP '^[0-9]{8}$')
|
||||
) ENGINE=InnoDB;
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
CREATE TABLE functions (
|
||||
id INT NOT NULL AUTO_INCREMENT,
|
||||
function_name VARCHAR(500) NOT NULL UNIQUE,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
entry_created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB;
|
||||
|
||||
INSERT INTO functions (function_name) VALUES ("Loan Mailer"), ("Loan Service"), ("Contact Form Service"), ("User Frontend"), ("API")
|
||||
@@ -1,8 +1,25 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import env from "dotenv";
|
||||
import dotenv from "dotenv";
|
||||
import info from "./info.json" assert { type: "json" };
|
||||
import { authenticate } from "./services/authentication.js";
|
||||
import { rateLimit } from "express-rate-limit";
|
||||
|
||||
dotenv.config();
|
||||
const app = express();
|
||||
const port = 8004;
|
||||
const naasURL = process.env.NAAS_URL;
|
||||
|
||||
const limiter = rateLimit({
|
||||
windowMs: 1 * 60 * 1000, // 1 minute
|
||||
limit: 50, // Limit each IP to 50 requests per `window` (here, per 1 minute).
|
||||
standardHeaders: "draft-8", // draft-6: `RateLimit-*` headers; draft-7 & draft-8: combined `RateLimit` header
|
||||
legacyHeaders: false, // Disable the `X-RateLimit-*` headers.
|
||||
ipv6Subnet: 56, // Set to 60 or 64 to be less aggressive, or 52 or 48 to be more aggressive
|
||||
// store: ... , // Redis, Memcached, etc. See below.
|
||||
});
|
||||
|
||||
app.use(limiter);
|
||||
|
||||
// frontend routes
|
||||
import loansMgmtRouter from "./routes/app/loanMgmt.route.js";
|
||||
@@ -14,14 +31,11 @@ import loanDataMgmtRouter from "./routes/admin/loanDataMgmt.route.js";
|
||||
import itemDataMgmtRouter from "./routes/admin/itemDataMgmt.route.js";
|
||||
import apiDataMgmtRouter from "./routes/admin/apiDataMgmt.route.js";
|
||||
import userMgmtRouterADMIN from "./routes/admin/userMgmt.route.js";
|
||||
import serverConfMgmtRouter from "./routes/admin/serverConfMgmt.route.js";
|
||||
|
||||
// API routes
|
||||
import apiRouter from "./routes/api/api.route.js";
|
||||
|
||||
env.config();
|
||||
const app = express();
|
||||
const port = 8102;
|
||||
|
||||
app.use(cors());
|
||||
// Body-Parser VOR den Routen registrieren
|
||||
app.use(express.json({ limit: "10mb" }));
|
||||
@@ -37,6 +51,7 @@ app.use("/api/admin/user-data", userDataMgmtRouter);
|
||||
app.use("/api/admin/item-data", itemDataMgmtRouter);
|
||||
app.use("/api/admin/api-data", apiDataMgmtRouter);
|
||||
app.use("/api/admin/user-mgmt", userMgmtRouterADMIN);
|
||||
app.use("/api/admin/server-config", serverConfMgmtRouter);
|
||||
|
||||
// API routes
|
||||
app.use("/api", apiRouter);
|
||||
@@ -47,6 +62,20 @@ app.listen(port, () => {
|
||||
console.log(`Server is running on port: ${port}`);
|
||||
});
|
||||
|
||||
app.get("/no", async (req, res) => {
|
||||
try {
|
||||
const response = await fetch(naasURL);
|
||||
if (!response.ok) {
|
||||
res.status(500).send("Request to no-as-a-service went wrong.");
|
||||
}
|
||||
const data = await response.json();
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
console.error("Error communicating with no-as-a-service:", error);
|
||||
res.status(500).send("Error communicating with no-as-a-service.");
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/verify", authenticate, async (req, res) => {
|
||||
res.status(200).json({ message: "Token is valid", user: req.user });
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import env from "dotenv";
|
||||
import { verifyAPIKeyDB } from "./database.js";
|
||||
import { checkIfServiceIsActive2 } from "./functions.js";
|
||||
env.config();
|
||||
|
||||
const api_service = "API";
|
||||
const user_frontend_service = "User Frontend";
|
||||
|
||||
const secretKey = process.env.SECRET_KEY;
|
||||
if (!secretKey) {
|
||||
throw new Error("Missing SECRET_KEY environment variable");
|
||||
@@ -45,6 +49,13 @@ export async function authenticate(req, res, next) {
|
||||
const apiKey = req.params.key;
|
||||
|
||||
if (authHeader) {
|
||||
const serviceActive = await checkIfServiceIsActive2(user_frontend_service);
|
||||
if (!serviceActive) {
|
||||
return res
|
||||
.status(503)
|
||||
.json({ message: "User Frontend is currently unavailable." });
|
||||
}
|
||||
|
||||
const parts = authHeader.split(" ");
|
||||
const scheme = parts[0];
|
||||
const token = parts[1];
|
||||
@@ -61,6 +72,13 @@ export async function authenticate(req, res, next) {
|
||||
return res.status(403).json({ message: "Present token invalid" }); // present token invalid
|
||||
}
|
||||
} else if (apiKey) {
|
||||
const serviceActive = await checkIfServiceIsActive2(api_service);
|
||||
if (!serviceActive) {
|
||||
return res
|
||||
.status(503)
|
||||
.json({ message: "API Service is currently unavailable." });
|
||||
}
|
||||
|
||||
try {
|
||||
await verifyAPIKey(apiKey);
|
||||
return next();
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import mysql from "mysql2";
|
||||
import dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
|
||||
const pool = mysql
|
||||
.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
})
|
||||
.promise();
|
||||
|
||||
export function checkIfServiceIsActive(service) {
|
||||
return async (req, res, next) => {
|
||||
const [result] = await pool.query(
|
||||
"SELECT * FROM functions WHERE function_name = ? AND active = 1;",
|
||||
[service],
|
||||
);
|
||||
|
||||
if (result.length > 0) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res
|
||||
.status(503)
|
||||
.json({ message: `-${service}- is currently unavailable.` });
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkIfServiceIsActive2(service) {
|
||||
const [result] = await pool.query(
|
||||
"SELECT * FROM functions WHERE function_name = ? AND active = 1;",
|
||||
[service],
|
||||
);
|
||||
|
||||
if (result.length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
# Changelog for upcoming version: v2.2
|
||||
|
||||
This update provides some new features for the design. It also contains some improvements and I have also fixed some bugs.
|
||||
|
||||
## New features
|
||||
|
||||
- The overview page now has the note column and is overall better organised
|
||||
- I also addded the regular header to the page
|
||||
- I have added three animations to the Borrow System
|
||||
- I have added a new icon for the frontend, which is now also used in the header and the favicon. It is a dark version of the old icon, which fits better to the overall design. I have made it with Icon Composer. The old icon is still used for the admin panel, which has a light design. (Maybe I will change the admin panel design in the future...)
|
||||
- When you go to your user card (over the user icon in the header) you have a new button "Click me". If you click it, you will get an message... _I am just saying: I have implemented the no-as-a-service code in to my Backend._
|
||||
|
||||
## Improvements
|
||||
|
||||
- I have the error logging for the API route wehre you can take loans improved.
|
||||
- If you try to delete a loan that has not been returned yet, you will get an 507 error code.
|
||||
|
||||
## Fixed bugs
|
||||
|
||||
- Fixed bug: #13
|
||||
- Fixed bug for messaging when server has an error
|
||||
- Fixed footer height
|
||||
|
||||
---
|
||||
|
||||
## New version numbers
|
||||
|
||||
**Backend:** v2.2
|
||||
|
||||
**Frontend:** v2.2
|
||||
|
||||
**Admin panel:** v1.3.2
|
||||
|
||||
---
|
||||
|
||||
-[Theis](https://portfolio-theis.de)
|
||||
@@ -1,51 +1,56 @@
|
||||
services:
|
||||
demo_usr_frontend:
|
||||
container_name: demo_borrow_system-usr-frontend
|
||||
networks:
|
||||
- proxynet
|
||||
build: ./FrontendV2
|
||||
restart: unless-stopped
|
||||
# usr-frontend_v2:
|
||||
# container_name: borrow_system-usr-frontend
|
||||
# build: ./FrontendV2
|
||||
# ports:
|
||||
# - "8001:80"
|
||||
# restart: always
|
||||
|
||||
demo_admin_frontend:
|
||||
container_name: demo_borrow_system-admin-frontend
|
||||
networks:
|
||||
- proxynet
|
||||
build: ./admin
|
||||
restart: unless-stopped
|
||||
# admin-frontend:
|
||||
# container_name: borrow_system-admin-frontend
|
||||
# build: ./admin
|
||||
# ports:
|
||||
# - "8003:80"
|
||||
# restart: always
|
||||
|
||||
demo_backend_v2:
|
||||
container_name: demo_borrow_system-backend_v2
|
||||
networks:
|
||||
- proxynet
|
||||
backend_v2:
|
||||
container_name: borrow_system-backend_v2
|
||||
build: ./backendV2
|
||||
ports:
|
||||
- "8004:8004"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DB_HOST: demo_mysql_v2
|
||||
DB_HOST: mysql_v2
|
||||
DB_USER: root
|
||||
DB_PASSWORD: ${DB_PASSWORD_V2}
|
||||
DB_NAME: borrow_system_new
|
||||
depends_on:
|
||||
- demo_mysql_v2
|
||||
restart: unless-stopped
|
||||
- mysql_v2
|
||||
restart: always
|
||||
|
||||
demo_mysql_v2:
|
||||
container_name: demo_borrow_system-mysql-v2
|
||||
networks:
|
||||
- proxynet
|
||||
mysql_v2:
|
||||
container_name: borrow_system-mysql-v2
|
||||
image: mysql:8.0
|
||||
restart: unless-stopped
|
||||
restart: always
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD_V2}
|
||||
MYSQL_DATABASE: borrow_system_new
|
||||
TZ: Europe/Berlin
|
||||
volumes:
|
||||
- demo_mysql-v2-data:/var/lib/mysql
|
||||
- mysql-v2-data:/var/lib/mysql
|
||||
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
|
||||
ports:
|
||||
- "3310:3306"
|
||||
|
||||
no-as-a-service:
|
||||
container_name: borrow_system-naas
|
||||
ports:
|
||||
- "3000:3000"
|
||||
build:
|
||||
context: ./no-as-a-service
|
||||
dockerfile: Dockerfile
|
||||
restart: always
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
demo_mysql-v2-data:
|
||||
|
||||
networks:
|
||||
proxynet:
|
||||
external: true
|
||||
mysql-v2-data:
|
||||
|
||||