62 Commits

Author SHA1 Message Date
233778a8ad added next js project/folder 2025-11-29 14:54:15 +01:00
31960d1ff8 fixed footer message 2025-11-25 18:02:05 +01:00
3bf5560834 edited docs 2025-11-25 17:09:22 +01:00
4c60fea4c4 fixed bug: mailer did not send email 2025-11-25 16:57:20 +01:00
0577a63205 deleted xls 2025-11-25 16:15:08 +01:00
fd2ccaa747 feat: add door_key field to items and update related logic in forms and database 2025-11-24 17:12:37 +01:00
df6b5eac59 fixed bug and edited version 2025-11-24 16:55:27 +01:00
d64489aed4 edited scheme but still not working 2025-11-24 16:05:56 +01:00
cb6b5858e5 refactor: rename lockerNumber to safe_nr and update related logic 2025-11-23 21:52:12 +01:00
85e6d7fe00 fixed bugs 2025-11-23 21:39:18 +01:00
4b9f55268c updated scheme 2025-11-23 21:25:08 +01:00
90ca266793 changed docker config 2025-11-23 20:26:49 +01:00
b9d67cd147 deleted old backend 2025-11-23 20:20:23 +01:00
58b5d29040 fixed rendering bug 2025-11-23 20:07:31 +01:00
baa74adcc1 updated scheme 2025-11-23 20:07:17 +01:00
07d194ee6a Refactor API and frontend components: update item state handling, adjust API key length, and improve table layout for MyLoansPage 2025-11-23 19:58:04 +01:00
0a4d981808 changed the design of the item table 2025-11-23 19:37:59 +01:00
a78118da8d refactored backend 2025-11-23 19:33:12 +01:00
8ce882a745 Enhance HomePage: bold start and end date labels for better visibility 2025-11-21 23:34:30 +01:00
c6571033b0 Fix routing and update translations: change landing page route to '/landingpage', update user info dialog, and enhance localization strings. 2025-11-21 23:31:41 +01:00
238cd9254a added user card. Not yet designed! 2025-11-21 17:22:32 +01:00
ca8030afbd Enhance user management: update User context and API to include first name, last name, and admin status 2025-11-21 17:10:48 +01:00
1076b12668 improved note making 2025-11-21 16:59:02 +01:00
80cb393768 Enhance item management: update API key display, add locker number input, and modify database schema for unique locker numbers 2025-11-21 16:55:18 +01:00
79486fe1cb updated translation 2025-11-21 13:43:32 +01:00
09ea1cb301 fixed bug: landingpage does not render content 2025-11-20 17:41:47 +01:00
db21bcf1b4 changed docs 2025-11-19 16:52:55 +01:00
4ec14416ca fixed some display bugs 2025-11-19 16:31:42 +01:00
6556d2c01d imroved translation 2025-11-18 10:20:10 +01:00
903e360c29 added new admin route for executing mysql commands 2025-11-18 10:18:25 +01:00
c5a9a09ef3 edited api docs 2025-11-17 22:55:23 +01:00
a191c9c053 refactored backend docs 2025-11-17 22:52:58 +01:00
084a0fa2e2 refactor: update API endpoints and enhance loan management features 2025-11-17 22:49:54 +01:00
88a2c74e88 adjusted gitignore 2025-11-17 21:38:26 +01:00
3a03457f5a adjusted new backend with new routes 2025-11-17 21:37:29 +01:00
757e13efe4 changed api and scheme 2025-11-17 21:20:57 +01:00
d2ee9d73c7 corrected routes 2025-11-13 22:02:14 +01:00
8c10e6e63f refactored docs 2025-11-13 20:48:06 +01:00
24bf5fcaaf Add file locations for authentication and API routes in documentation 2025-11-11 21:12:00 +01:00
6f03fd8032 Update API documentation to clarify API key requirements 2025-11-11 21:08:10 +01:00
17010d5480 edited docs 2025-11-11 21:06:13 +01:00
a8c5ef25f7 fixed 403 bug 2025-11-11 21:01:09 +01:00
eccd0135fc added api route. But still with bug: still getting 403 but have valid api key 2025-11-11 20:46:21 +01:00
8f294278d4 Add API routing and remove unused imports in user management 2025-11-11 17:32:26 +01:00
16e48aaf3f improved table view 2025-11-11 17:18:16 +01:00
e49700071b imrpoved table view 2025-11-11 17:11:20 +01:00
a8b4ac3d60 Refactor loan and user management components and backend routes
- Updated LoanTable component to fetch loan data from new API endpoint and display notes.
- Enhanced UserTable component to include additional user fields (first name, last name, email, admin status) and updated input handling.
- Modified fetcher utility to use new user data API endpoint.
- Adjusted login functionality to point to the new admin login endpoint and handle unauthorized access.
- Refactored user actions utility to align with updated API endpoints for user management.
- Updated backend routes for user and loan data management to reflect new structure and naming conventions.
- Revised SQL schema and mock data to accommodate new fields and constraints.
- Changed Docker configuration to use the new database name.
2025-11-11 17:08:45 +01:00
974a5a75d8 improved 2025-11-11 14:24:37 +01:00
b9783a1909 added api data admin route 2025-11-10 10:21:42 +01:00
304e73b459 Implement item and loan management routes with CRUD operations 2025-11-08 17:14:29 +01:00
12277abb9e completed userDataMgmt 2025-11-08 16:59:07 +01:00
20d22d6ce4 enhanced structure 2025-11-06 17:53:12 +01:00
27d21efefa began to refactor backend 2025-11-05 10:25:23 +01:00
3e67bf9052 edited docker config for backend 2025-11-03 21:12:58 +01:00
3438321765 refactored backendV2 2025-11-03 21:09:31 +01:00
29d47ddd9b refactor: update Dockerfiles and nginx configurations for consistency and optimization 2025-11-03 21:05:21 +01:00
7b298180e0 edited dockker config 2025-11-03 20:42:52 +01:00
9b3bd76c42 edited names 2025-11-02 21:22:25 +01:00
5b73b44e79 refactored apiKeys table structure and added new route files for loans and user management 2025-11-02 21:19:02 +01:00
cf4a003c51 removed writing error 2025-11-02 21:18:35 +01:00
592b60082b refactored api routes 2025-11-02 17:14:36 +01:00
a34292bda1 refactored authentication 2025-11-02 17:14:28 +01:00
92 changed files with 10096 additions and 3845 deletions

1
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -1,58 +1,90 @@
# Backend API docs (apiV2) # Borrow System API Documentation
If you want to cooperate with me, or build something new with my backend API, feel free to reach out! **Frontend:** https://insta.the1s.de
**Backend base URL:** `https://backend.insta.the1s.de/api`
On this page you will learn how my API works.
## General information
When you look at my backend folder and file structure, you can see that I have two files called `API`. The first file called `api.js` which is for my web frontend, because this file works together with my JWT token service.
But I have built a second API. You can see the second API file in the same directory, the file is called `apiV2.js`.
But first you have to get an API Key. You can get the API key from my admin dashboard. When you don't have any access to my admin dashboard, please contact your administrator or me.
---
## Base URL
- Frontend: `https://insta.the1s.de`
- Backend: `https://backend.insta.the1s.de`
- Base path for this API: `https://backend.insta.the1s.de/apiV2`
You can see the status of this and all my other services at `https://status.the1s.de`.
_I have also build a [fallback page](https://git.the1s.de/theis.gaedigk/fallback-page). When only the application is down, you will see a friendly message and a link to the status page. (Only if the server is not down)_
--- ---
## Authentication ## Authentication
All endpoints require an API key as a path parameter named `:key`. All API endpoints require **either**:
Example: `/apiV2/items/:key` ### 1. Bearer Token (JWT)
If the key is missing or invalid, the API responds with `401 Unauthorized`. Send an `Authorization` header:
```http
Authorization: Bearer <JWT_TOKEN>
```
- Used for user-based access.
- Token must be valid and not expired.
### 2. API Key (for devices / machine-to-machine)
Include an API key in the route as `:key` parameter:
```text
/api/.../:key/...
```
Example:
```http
GET /api/items/ABC123
```
Where `ABC123` is your API key.
The API key is validated server-side.
---
## 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.
--- ---
## Endpoints ## Endpoints
### 1) Get all items ### 1. Get All Items
GET `/apiV2/items/:key` **GET** `/api/items/:key`
Returns a list of all items wrapped in a `data` object. Returns a list of all items.
Example request: #### Path Parameters
``` - `:key` API key (string)
GET https://backend.insta.the1s.de/apiV2/items/12345
#### Authentication
- Either:
- Valid `Authorization: Bearer <token>`
- Or valid `:key` path parameter
#### Request Example
```http
GET /api/items/ABC123 HTTP/1.1
Host: backend.insta.the1s.de
``` ```
Example response: or
```http
GET /api/items/dummyKey HTTP/1.1
Host: backend.insta.the1s.de
Authorization: Bearer <JWT_TOKEN>
``` ```
#### Successful Response (200)
```json
{ {
"data": [ "data": [
{ {
@@ -60,151 +92,282 @@ Example response:
"item_name": "DJI 1er Mikro", "item_name": "DJI 1er Mikro",
"can_borrow_role": 4, "can_borrow_role": 4,
"inSafe": 1, "inSafe": 1,
"entry_created_at": "2025-08-19T22:02:16.000Z" "safe_nr": 3,
"door_key": "123",
"entry_created_at": "2025-08-19T22:02:16.000Z",
"entry_updated_at": "2025-08-19T22:02:16.000Z",
"last_borrowed_person": "alice",
"currently_borrowing": null
} }
] ]
} }
``` ```
Fields: #### Error Response (500)
- `id`: Unique identifier ```json
- `item_name`: Item name {
- `can_borrow_role`: Role allowed to borrow "message": "Failed to fetch items"
- `inSafe`: 1 if in locker, 0 otherwise }
- `entry_created_at`: Creation timestamp ```
Status: 200 on success, 500 on failure.
--- ---
### 2) Change item safe state ### 2. Toggle Item Safe State
POST `/apiV2/controlInSafe/:key/:itemId/:state` Toggles `in_safe` between `0` and `1` for a given item.
Updates `inSafe` (locker) state of an item. **Keep in mind that when you return a loan by code, the item states are automatically updated.**
- `state` must be `"1"` (in safe) or `"0"` (not in safe) **POST** `/api/change-state/:key/:itemId`
Example request: #### Path Parameters
``` - `:key` API key (string)
POST https://backend.insta.the1s.de/apiV2/controlInSafe/12345/123/1 - `:itemId` Item ID (integer)
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
POST /api/change-state/ABC123/42 HTTP/1.1
Host: backend.insta.the1s.de
``` ```
Example response (shape depends on database service): #### Successful Response (200)
``` ```json
{ "data": { /* update result */ } } {
"data": {}
}
``` ```
Status: _(Implementation currently only returns `{ success: true }`, so `data` may be empty.)_
- 200 on success #### Error Response (500)
- 400 if `state` is invalid
- 500 on failure
**You can get the item id on the admin panel, from your system administrator.** ```json
{
"message": "Failed to update item state"
}
```
--- ---
### 3) Get loan by code ### 3. Get Loan by Code
GET `/apiV2/getLoanByCode/:key/:loan_code` Fetch loan information by `loan_code`.
Retrieves the details of a specific loan. **GET** `/api/get-loan-by-code/:key/:loan_code`
Example request: #### Path Parameters
``` - `:key` API key (string)
GET https://backend.insta.the1s.de/apiV2/getLoanByCode/12345/123456 - `:loan_code` Loan code (string)
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
GET /api/get-loan-by-code/ABC123/12345 HTTP/1.1
Host: backend.insta.the1s.de
``` ```
Example response: #### Successful Response (200)
``` ```json
{ {
"data": { "data": {
"id": 6, "username": "john",
"username": "theis",
"loan_code": 646473,
"start_date": "2025-08-25T13:23:00.000Z",
"end_date": "2025-08-26T13:23:00.000Z",
"take_date": null,
"returned_date": null, "returned_date": null,
"created_at": "2025-08-20T11:23:40.000Z", "take_date": "2025-01-01T10:00:00.000Z",
"loaned_items_id": [8, 9], "lockers": "[1, 2, 3]"
"loaned_items_name": ["SD Karten", "Kameragimbal"]
} }
} }
``` ```
Status: #### Error Response (404)
- 200 on success ```json
- 404 if not found {
"message": "Loan not found"
}
```
--- ---
### 4) Set return date (now) by loan code ### 4. Set Loan Return Date
POST `/apiV2/setReturnDate/:key/:loan_code` Sets `returned_date = NOW()` on a loan and updates related items:
Sets the `returned_date` to the current server time. - `in_safe = 1`
- `currently_borrowing = NULL`
- `last_borrowed_person = username`
**Note:** I have updated this API route, so that everytime you return or take a loan, the state of the loaned items is automatically updated. **POST** `/api/set-return-date/:key/:loan_code`
**DO NOT UPDATE THE STATE MANUALLY! (only if the item was taken with an admin key)** #### Path Parameters
Example request: - `:key` API key (string)
- `:loan_code` Loan code (string)
``` #### Authentication
POST https://backend.insta.the1s.de/apiV2/setReturnDate/12345/123456
- Either Bearer token or `:key` API key.
#### Request Example
```http
POST /api/set-return-date/ABC123/12345 HTTP/1.1
Host: backend.insta.the1s.de
``` ```
Example response: #### Successful Response (200)
``` ```json
{ "data": { /* update result */ } } {
"data": {}
}
``` ```
Status: 200 on success, 500 on failure. #### Error Response (500)
```json
{
"message": "Failed to set return date"
}
```
--- ---
### 5) Set take date (now) by loan code ### 5. Set Loan Take Date
POST `/apiV2/setTakeDate/:key/:loan_code` Sets `take_date = NOW()` on a loan and updates related items:
Sets the `take_date` to the current server time. - `in_safe = 0`
- `currently_borrowing = username`
**Note:** I have updated this API route, so that everytime you return or take a loan, the state of the loaned items is automatically updated. **POST** `/api/set-take-date/:key/:loan_code`
**DO NOT UPDATE THE STATE MANUALLY! (only if the item was taken with an admin key)** #### Path Parameters
Example request: - `:key` API key (string)
- `:loan_code` Loan code (string)
``` #### Authentication
POST https://backend.insta.the1s.de/apiV2/setTakeDate/12345/123456
- Either Bearer token or `:key` API key.
#### Request Example
```http
POST /api/set-take-date/ABC123/LOAN-12345 HTTP/1.1
Host: backend.insta.the1s.de
``` ```
Example response: #### Successful Response (200)
``` ```json
{ "data": { /* update result */ } } {
"data": {}
}
``` ```
Status: 200 on success, 500 on failure. #### Error Response (500)
```json
{
"message": "Failed to set take date"
}
```
--- ---
## Error handling ### 6. Open Door by Door Key
- 401 Unauthorized: Missing or invalid API key Looks up an item by its `door_key`, toggles `in_safe`, and returns safe information.
- 400 Bad Request: Invalid parameters (e.g., wrong state value)
- 404 Not Found: Loan not found **GET** `/api/open-door/:key/:doorKey`
- 500 Internal Server Error: Database or server error
#### Path Parameters
- `:key` API key (string)
- `:doorKey` Door key/token (string) used by hardware to identify the locker.
#### Authentication
- Either Bearer token or `:key` API key.
#### Request Example
```http
GET /api/open-door/ABC123/123 HTTP/1.1
Host: backend.insta.the1s.de
```
#### Successful Response (200)
```json
{
"data": {
"safe_nr": 5,
"id": 42
}
}
```
#### Error Response (500)
```json
{
"message": "Failed to open door"
}
```
--- ---
If you have questions or want to collaborate, please reach out! ## 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.

View File

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

18
FrontendV2/nginx.conf Normal file
View File

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

View File

@@ -27,7 +27,7 @@ function App() {
useEffect(() => { useEffect(() => {
if (Cookies.get("token")) { if (Cookies.get("token")) {
const verifyToken = async () => { const verifyToken = async () => {
const response = await fetch(`${API_BASE}/api/verifyToken`, { const response = await fetch(`${API_BASE}/verify`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,
@@ -36,7 +36,13 @@ function App() {
if (response.ok) { if (response.ok) {
setTriggerLogout(false); setTriggerLogout(false);
const data = await response.json(); const data = await response.json();
setUser({ username: data.user.username, role: data.user.role }); setUser({
username: data.user.username,
is_admin: data.user.is_admin,
first_name: data.user.first_name,
last_name: data.user.last_name,
role: data.user.role,
});
setIsLoggedIn(true); setIsLoggedIn(true);
} else { } else {
Cookies.remove("token"); Cookies.remove("token");
@@ -73,7 +79,7 @@ function App() {
<Route element={<ProtectedRoutes />}> <Route element={<ProtectedRoutes />}>
<Route path="/" element={<HomePage />} /> <Route path="/" element={<HomePage />} />
<Route path="/my-loans" element={<MyLoansPage />} /> <Route path="/my-loans" element={<MyLoansPage />} />
<Route path="/landing" element={<Landingpage />} /> <Route path="/landingpage" element={<Landingpage />} />
</Route> </Route>
<Route path="/login" element={<LoginPage />} /> <Route path="/login" element={<LoginPage />} />

View File

@@ -1,5 +1,4 @@
import { import {
Badge,
Button, Button,
Flex, Flex,
Heading, Heading,
@@ -12,6 +11,9 @@ import {
IconButton, IconButton,
Menu, Menu,
Box, Box,
Avatar,
Card,
Grid,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { PasswordInput } from "@/components/ui/password-input"; import { PasswordInput } from "@/components/ui/password-input";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
@@ -26,7 +28,8 @@ import {
LogOut, LogOut,
CalendarPlus, CalendarPlus,
MoreVertical, MoreVertical,
Flag, Languages,
Table,
} from "lucide-react"; } from "lucide-react";
import { useUserContext } from "@/states/Context"; import { useUserContext } from "@/states/Context";
import { useState } from "react"; import { useState } from "react";
@@ -37,6 +40,7 @@ import { API_BASE } from "@/config/api.config";
export const Header = () => { export const Header = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const userData = useUserContext(); const userData = useUserContext();
console.log(userData);
const { t } = useTranslation(); const { t } = useTranslation();
// Error handling states // Error handling states
@@ -54,6 +58,7 @@ export const Header = () => {
// Dialog control // Dialog control
const [isPwOpen, setPwOpen] = useState(false); const [isPwOpen, setPwOpen] = useState(false);
const [userDialog, setUserDialog] = useState(false);
const changePassword = async () => { const changePassword = async () => {
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
@@ -64,7 +69,7 @@ export const Header = () => {
return; return;
} }
const response = await fetch(`${API_BASE}/api/changePassword`, { const response = await fetch(`${API_BASE}/api/users/change-password`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -91,9 +96,20 @@ export const Header = () => {
setConfirmPassword(""); setConfirmPassword("");
}; };
const username = userData?.username const username = userData.first_name ? userData.first_name : "N/A";
? userData.username[0].toUpperCase() + userData.username.slice(1) const fullname = userData.first_name + " " + userData.last_name;
: "User"; const randomColor = [
"gray",
"red",
"orange",
"yellow",
"green",
"teal",
"blue",
"cyan",
"purple",
"pink",
];
const logout = () => { const logout = () => {
Cookies.remove("token"); Cookies.remove("token");
@@ -155,12 +171,12 @@ export const Header = () => {
} }
/> />
<Menu.Item <Menu.Item
value="change-password" value="landingpage"
onSelect={() => setPwOpen(true)} onSelect={() => navigate("/landingpage", { replace: true })}
children={ children={
<HStack gap={3}> <HStack gap={3}>
<RotateCcwKey size={16} /> <Table size={16} />
<Text as="span">{t("change-password")}</Text> <Text as="span">{t("landingpage")}</Text>
</HStack> </HStack>
} }
/> />
@@ -174,7 +190,7 @@ export const Header = () => {
}} }}
children={ children={
<HStack gap={3}> <HStack gap={3}>
<LifeBuoy size={16} /> <Languages size={16} />
<Text as="span">{t("change-language")}</Text> <Text as="span">{t("change-language")}</Text>
</HStack> </HStack>
} }
@@ -241,7 +257,7 @@ export const Header = () => {
size="2xl" size="2xl"
className="tracking-tight text-slate-900 dark:text-slate-100" className="tracking-tight text-slate-900 dark:text-slate-100"
> >
Home {t("app-title")}
</Heading> </Heading>
</Flex> </Flex>
@@ -250,12 +266,21 @@ export const Header = () => {
{t("greeting")} {t("greeting")}
<strong>{username}</strong>! <strong>{username}</strong>!
</Text> </Text>
<Badge variant="subtle" px={2} py={1} borderRadius="full">
Rolle: {userData?.role ?? "—"}
</Badge>
</HStack> </HStack>
</Stack> </Stack>
{/* Avatar: visible on mobile, hidden on desktop (desktop version is in the actions bar) */}
<HStack display={{ base: "flex", md: "none" }}>
<Avatar.Root>
<button
onClick={() => setUserDialog(true)}
style={{ cursor: "pointer" }}
>
<Avatar.Fallback name={fullname} />
</button>
</Avatar.Root>
</HStack>
{/* Right: Actions */} {/* Right: Actions */}
{/* Desktop actions */} {/* Desktop actions */}
<HStack <HStack
@@ -265,6 +290,18 @@ export const Header = () => {
flexWrap="wrap" flexWrap="wrap"
display={{ base: "none", md: "flex" }} display={{ base: "none", md: "flex" }}
> >
{/* Desktop avatar, aligned with action buttons */}
<Avatar.Root
colorPalette={randomColor[Math.floor(Math.random() * 10)]}
>
<button
onClick={() => setUserDialog(true)}
style={{ cursor: "pointer" }}
>
<Avatar.Fallback name={fullname} />
</button>
</Avatar.Root>
<Button <Button
colorScheme="teal" colorScheme="teal"
onClick={() => navigate("/", { replace: true })} onClick={() => navigate("/", { replace: true })}
@@ -282,10 +319,10 @@ export const Header = () => {
</HStack> </HStack>
</Button> </Button>
<Button variant="ghost" onClick={() => setPwOpen(true)}> <Button onClick={() => navigate("/landingpage", { replace: true })}>
<HStack gap={2}> <HStack gap={2}>
<RotateCcwKey size={18} /> <Table size={18} />
<Text as="span">{t("change-password")}</Text> <Text as="span">{t("landingpage")}</Text>
</HStack> </HStack>
</Button> </Button>
@@ -299,7 +336,7 @@ export const Header = () => {
}} }}
> >
<HStack gap={2}> <HStack gap={2}>
<Flag size={18} /> <Languages size={18} />
<Text as="span">{t("change-language")}</Text> <Text as="span">{t("change-language")}</Text>
</HStack> </HStack>
</Button> </Button>
@@ -337,6 +374,86 @@ export const Header = () => {
</HStack> </HStack>
</Flex> </Flex>
{/* User Info Dialoge */}
{userDialog && (
<Flex
position="fixed"
inset={0}
zIndex={1000}
align="center"
justify="center"
bg="blackAlpha.400"
backdropFilter="blur(6px)"
>
<Card.Root maxW="sm" w="full" mx={4}>
<Card.Header>
<Card.Title>
<Flex justify="center" align="center" w="100%">
<Avatar.Root
size={"2xl"}
colorPalette={randomColor[Math.floor(Math.random() * 10)]}
>
<Avatar.Fallback name={fullname} />
</Avatar.Root>
</Flex>
</Card.Title>
<Card.Description>{t("user-info-desc")}</Card.Description>
</Card.Header>
<Card.Body>
<Stack gap="4" w="full">
<Box as="dl">
<Grid
templateColumns="auto 1fr"
rowGap={2}
columnGap={4}
alignItems="start"
>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("first-name")}:
</Text>
<Text as="dd">{userData.first_name}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("last-name")}:
</Text>
<Text as="dd">{userData.last_name}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("username")}:
</Text>
<Text as="dd">{userData.username}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("role")}:
</Text>
<Text as="dd">{userData.role}</Text>
<Text as="dt" fontWeight="bold" textAlign="left">
{t("admin-status")}:
</Text>
<Text as="dd">
{userData.is_admin ? t("yes") : t("no")}
</Text>
</Grid>
</Box>
<Button variant="solid" onClick={() => setPwOpen(true)}>
<HStack gap={2}>
<RotateCcwKey size={18} />
<Text as="span">{t("change-password")}</Text>
</HStack>
</Button>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
<Button variant="outline" onClick={() => setUserDialog(false)}>
{t("cancel")}
</Button>
</Card.Footer>
</Card.Root>
</Flex>
)}
{/* Passwort-Dialog (kontrolliert) */} {/* Passwort-Dialog (kontrolliert) */}
<Dialog.Root open={isPwOpen} onOpenChange={(e: any) => setPwOpen(e.open)}> <Dialog.Root open={isPwOpen} onOpenChange={(e: any) => setPwOpen(e.open)}>
<Portal> <Portal>

View File

@@ -14,7 +14,7 @@ export const Footer = () => {
left="0" left="0"
right="0" right="0"
> >
Made with by Theis Gaedigk - Year 2019 at MCS-Bochum Made with by Theis Gaedigk - Class of 2019 at MCS-Bochum
<br /> <br />
Frontend-Version: {info ? info["frontend-info"].version : "N/A"} | Frontend-Version: {info ? info["frontend-info"].version : "N/A"} |
Backend-Version: {info ? info["backend-info"].version : "N/A"} Backend-Version: {info ? info["backend-info"].version : "N/A"}

View File

@@ -5,7 +5,7 @@ export const useVersionInfoQuery = () =>
useQuery({ useQuery({
queryKey: ["versionInfo"], queryKey: ["versionInfo"],
queryFn: async () => { queryFn: async () => {
const response = await fetch(`${API_BASE}/server-info`, { const response = await fetch(`${API_BASE}/`, {
method: "GET", method: "GET",
}); });
if (response.ok) { if (response.ok) {

View File

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

View File

@@ -7,6 +7,8 @@ import {
Spinner, Spinner,
VStack, VStack,
Table, Table,
InputGroup,
Span,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { getBorrowableItems } from "@/utils/Fetcher"; import { getBorrowableItems } from "@/utils/Fetcher";
@@ -31,6 +33,9 @@ export const HomePage = () => {
const [isLoadingA, setIsLoadingA] = useState(false); const [isLoadingA, setIsLoadingA] = useState(false);
const [selectedItems, setSelectedItems] = useState<number[]>([]); const [selectedItems, setSelectedItems] = useState<number[]>([]);
const MAX_CHARACTERS = 500;
const [note, setNote] = useState("");
// Error handling states // Error handling states
const [isMsg, setIsMsg] = useState(false); const [isMsg, setIsMsg] = useState(false);
const [msgStatus, setMsgStatus] = useState<"error" | "success">("error"); const [msgStatus, setMsgStatus] = useState<"error" | "success">("error");
@@ -58,7 +63,9 @@ export const HomePage = () => {
<Stack as="main"> <Stack as="main">
<Text>{t("timezone-info")}</Text> <Text>{t("timezone-info")}</Text>
<label htmlFor="startDate"> <label htmlFor="startDate">
<Text>{t("start-date")}</Text> <strong>
<Text>{t("start-date")}</Text>
</strong>
</label> </label>
<Input <Input
id="startDate" id="startDate"
@@ -68,7 +75,9 @@ export const HomePage = () => {
onChange={(e) => setStartDate(e.target.value)} onChange={(e) => setStartDate(e.target.value)}
/> />
<label htmlFor="endDate"> <label htmlFor="endDate">
<Text>{t("end-date")}</Text> <strong>
<Text>{t("end-date")}</Text>
</strong>
</label> </label>
<Input <Input
id="endDate" id="endDate"
@@ -135,6 +144,28 @@ export const HomePage = () => {
<Table.Cell>{item.item_name}</Table.Cell> <Table.Cell>{item.item_name}</Table.Cell>
</Table.Row> </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.Body>
</Table.Root> </Table.Root>
</Table.ScrollArea> </Table.ScrollArea>
@@ -142,19 +173,23 @@ export const HomePage = () => {
{selectedItems.length >= 1 && ( {selectedItems.length >= 1 && (
<Button <Button
onClick={() => onClick={() =>
createLoan(selectedItems, startDate, endDate).then((response) => { createLoan(selectedItems, startDate, endDate, note).then(
if (response.status === "error") { (response) => {
setMsgStatus("error"); if (response.status === "error") {
setMsgTitle(response.title || t("error")); setMsgStatus("error");
setMsgDescription(response.description || t("unknown-error")); setMsgTitle(response.title || t("error"));
setMsgDescription(
response.description || t("unknown-error")
);
setIsMsg(true);
return;
}
setMsgStatus("success");
setMsgTitle(t("success"));
setMsgDescription(t("loan-success"));
setIsMsg(true); setIsMsg(true);
return;
} }
setMsgStatus("success"); )
setMsgTitle(t("success"));
setMsgDescription(t("loan-success"));
setIsMsg(true);
})
} }
> >
{t("create-loan")} {t("create-loan")}

View File

@@ -10,10 +10,11 @@ import {
SimpleGrid, SimpleGrid,
Button, Button,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { Lock, LockOpen } from "lucide-react";
import MyAlert from "@/components/myChakra/MyAlert"; import MyAlert from "@/components/myChakra/MyAlert";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { API_BASE } from "@/config/api.config"; import { API_BASE } from "@/config/api.config";
import Cookies from "js-cookie";
import { useNavigate } from "react-router-dom";
export const formatDateTime = (value: string | null | undefined) => { export const formatDateTime = (value: string | null | undefined) => {
if (!value) return "N/A"; if (!value) return "N/A";
@@ -37,12 +38,15 @@ type Device = {
id: number; id: number;
item_name: string; item_name: string;
can_borrow_role: string; can_borrow_role: string;
inSafe: number; in_safe: number;
entry_created_at: string; entry_created_at: string;
last_borrowed_person: string | null;
currently_borrowing: string | null;
}; };
const Landingpage: React.FC = () => { const Landingpage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [loans, setLoans] = useState<Loan[]>([]); const [loans, setLoans] = useState<Loan[]>([]);
@@ -68,7 +72,12 @@ const Landingpage: React.FC = () => {
const fetchData = async () => { const fetchData = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const loanRes = await fetch(`${API_BASE}/apiV2/allLoans`); const loanRes = await fetch(`${API_BASE}/api/loans/all-loans`, {
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
const loanData = await loanRes.json(); const loanData = await loanRes.json();
if (Array.isArray(loanData)) { if (Array.isArray(loanData)) {
setLoans(loanData); setLoans(loanData);
@@ -80,7 +89,12 @@ const Landingpage: React.FC = () => {
); );
} }
const deviceRes = await fetch(`${API_BASE}/apiV2/allItems`); const deviceRes = await fetch(`${API_BASE}/api/loans/all-items`, {
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
const deviceData = await deviceRes.json(); const deviceData = await deviceRes.json();
if (Array.isArray(deviceData)) { if (Array.isArray(deviceData)) {
setDevices(deviceData); setDevices(deviceData);
@@ -106,6 +120,10 @@ const Landingpage: React.FC = () => {
Matthias-Claudius-Schule Technik Matthias-Claudius-Schule Technik
</Heading> </Heading>
<Button onClick={() => navigate("/", { replace: true })}>
{t("back")}
</Button>
<Heading as="h2" size="md" mb={4}> <Heading as="h2" size="md" mb={4}>
{t("all-loans")} {t("all-loans")}
</Heading> </Heading>
@@ -145,10 +163,10 @@ const Landingpage: React.FC = () => {
<strong>{t("rented-items")}</strong> <strong>{t("rented-items")}</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader> <Table.ColumnHeader>
<strong>{t("return-date")}</strong> <strong>{t("take-date")}</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader> <Table.ColumnHeader>
<strong>{t("take-date")}</strong> <strong>{t("return-date")}</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
@@ -164,8 +182,8 @@ const Landingpage: React.FC = () => {
? loan.loaned_items_name.join(", ") ? loan.loaned_items_name.join(", ")
: loan.loaned_items_name} : loan.loaned_items_name}
</Table.Cell> </Table.Cell>
<Table.Cell>{formatDateTime(loan.returned_date)}</Table.Cell>
<Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell> <Table.Cell>{formatDateTime(loan.take_date)}</Table.Cell>
<Table.Cell>{formatDateTime(loan.returned_date)}</Table.Cell>
</Table.Row> </Table.Row>
))} ))}
</Table.Body> </Table.Body>
@@ -188,17 +206,26 @@ const Landingpage: React.FC = () => {
<Card.Root <Card.Root
key={device.id} key={device.id}
size="sm" size="sm"
bg={device.inSafe ? "green" : "red"} bg={device.in_safe ? "green" : "red"}
h="full" h="full"
minH="100px" minH="100px"
> >
<Card.Header> <Card.Header>
{device.inSafe ? <LockOpen size={16} /> : <Lock size={16} />} <Heading size="md">
<Heading size="md">{device.item_name}</Heading> <strong>{device.item_name}</strong>
</Heading>
</Card.Header> </Card.Header>
<Card.Body color="fg.muted"> <Card.Body>
<Text> <Text>
{t("rent-role")}: {device.can_borrow_role} <strong>{t("role")}</strong>: {device.can_borrow_role}
</Text>
<Text>
<strong>{t("last-borrowed-person")}</strong>:{" "}
{device.last_borrowed_person || "N/A"}
</Text>
<Text>
<strong>{t("currently-borrowed-by")}</strong>:{" "}
{device.currently_borrowing || "N/A"}
</Text> </Text>
</Card.Body> </Card.Body>
</Card.Root> </Card.Root>
@@ -217,7 +244,6 @@ const Landingpage: React.FC = () => {
borderRadius="full" borderRadius="full"
> >
<HStack gap={2}> <HStack gap={2}>
<LockOpen size={16} />
<Text>{t("in-locker")}</Text> <Text>{t("in-locker")}</Text>
</HStack> </HStack>
</Button> </Button>
@@ -230,7 +256,6 @@ const Landingpage: React.FC = () => {
borderRadius="full" borderRadius="full"
> >
<HStack gap={2}> <HStack gap={2}>
<Lock size={16} />
<Text>{t("not-in-locker")}</Text> <Text>{t("not-in-locker")}</Text>
</HStack> </HStack>
</Button> </Button>

View File

@@ -25,7 +25,7 @@ export const LoginPage = () => {
}, [isLoggedIn, navigate]); }, [isLoggedIn, navigate]);
const loginFnc = async (username: string, password: string) => { const loginFnc = async (username: string, password: string) => {
const response = await fetch(`${API_BASE}/api/login`, { const response = await fetch(`${API_BASE}/api/users/login`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),

View File

@@ -13,6 +13,7 @@ import {
Dialog, Dialog,
Portal, Portal,
Code, Code,
Box,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { Header } from "@/components/Header"; import { Header } from "@/components/Header";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
@@ -43,7 +44,7 @@ export const MyLoansPage = () => {
const fetchLoans = async () => { const fetchLoans = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const res = await fetch(`${API_BASE}/api/userLoans`, { const res = await fetch(`${API_BASE}/api/loans/loans`, {
method: "GET", method: "GET",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,
@@ -75,7 +76,7 @@ export const MyLoansPage = () => {
const deleteLoan = async (loanId: number) => { const deleteLoan = async (loanId: number) => {
try { try {
const res = await fetch(`${API_BASE}/api/SETdeleteLoan/${loanId}`, { const res = await fetch(`${API_BASE}/api/loans/delete-loan/${loanId}`, {
method: "DELETE", method: "DELETE",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,
@@ -129,110 +130,127 @@ export const MyLoansPage = () => {
</VStack> </VStack>
)} )}
{loans && ( {loans && (
<Table.Root <Box
size="sm" overflowX="auto"
variant="outline" width="100%"
style={{ tableLayout: "fixed", width: "100%" }} // Optional: add bottom padding to avoid scrollbar overlap
pb={2}
> >
<Table.ColumnGroup> <Table.Root
{/* Ausleihcode */} size="sm"
<Table.Column style={{ width: "14%" }} /> variant="outline"
{/* Startdatum */} // minWidth ensures we don't cram all columns on tiny screens;
<Table.Column style={{ width: "14%" }} /> // horizontal scrolling will appear instead.
{/* Enddatum */} style={{ tableLayout: "fixed", width: "100%", minWidth: "800px" }}
<Table.Column style={{ width: "14%" }} /> >
{/* Geräte (flexibler) */} <Table.ColumnGroup>
<Table.Column style={{ width: "28%" }} /> {/* Ausleihcode */}
{/* Ausleihdatum */} <Table.Column style={{ width: "14%" }} />
<Table.Column style={{ width: "14%" }} /> {/* Startdatum */}
{/* Rückgabedatum */} <Table.Column style={{ width: "14%" }} />
<Table.Column style={{ width: "14%" }} /> {/* Enddatum */}
{/* Aktionen */} <Table.Column style={{ width: "14%" }} />
<Table.Column style={{ width: "8%" }} /> {/* Geräte (flexibler) */}
</Table.ColumnGroup> <Table.Column style={{ width: "28%" }} />
<Table.Header> {/* Ausleihdatum */}
<Table.Row> <Table.Column style={{ width: "14%" }} />
<Table.ColumnHeader>{t("loan-code")}</Table.ColumnHeader> {/* Rückgabedatum */}
<Table.ColumnHeader>{t("start-date")}</Table.ColumnHeader> <Table.Column style={{ width: "14%" }} />
<Table.ColumnHeader>{t("end-date")}</Table.ColumnHeader> {/* Notiz */}
<Table.ColumnHeader>{t("devices")}</Table.ColumnHeader> <Table.Column style={{ width: "14%" }} />
<Table.ColumnHeader>{t("take-date")}</Table.ColumnHeader> {/* Aktionen */}
<Table.ColumnHeader>{t("return-date")}</Table.ColumnHeader> <Table.Column style={{ width: "8%" }} />
<Table.ColumnHeader>{t("actions")}</Table.ColumnHeader> </Table.ColumnGroup>
</Table.Row> <Table.Header>
</Table.Header> <Table.Row>
<Table.Body> <Table.ColumnHeader>{t("loan-code")}</Table.ColumnHeader>
{loans.map((loan) => ( <Table.ColumnHeader>{t("start-date")}</Table.ColumnHeader>
<Table.Row key={loan.id}> <Table.ColumnHeader>{t("end-date")}</Table.ColumnHeader>
<Table.Cell> <Table.ColumnHeader>{t("devices")}</Table.ColumnHeader>
<Text title={loan.loan_code}> <Table.ColumnHeader>{t("take-date")}</Table.ColumnHeader>
<Code variant="solid">{`${loan.loan_code}`}</Code> <Table.ColumnHeader>{t("return-date")}</Table.ColumnHeader>
</Text> <Table.ColumnHeader>{t("note")}</Table.ColumnHeader>
</Table.Cell> <Table.ColumnHeader>{t("actions")}</Table.ColumnHeader>
<Table.Cell>{formatDate(loan.start_date)}</Table.Cell>
<Table.Cell>{formatDate(loan.end_date)}</Table.Cell>
<Table.Cell>
<Text title={loan.loaned_items_name}>
{loan.loaned_items_name}
</Text>
</Table.Cell>
<Table.Cell>{formatDate(loan.take_date)}</Table.Cell>
<Table.Cell>{formatDate(loan.returned_date)}</Table.Cell>
<Table.Cell>
<Dialog.Root role="alertdialog">
<Dialog.Trigger asChild>
<Button
onClick={() => setDelLoanCode(loan.loan_code)}
aria-label="Ausleihe löschen"
style={{
display: "inline-flex",
alignItems: "center",
}}
>
<Trash2 />
</Button>
</Dialog.Trigger>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{t("sure")}</Dialog.Title>
</Dialog.Header>
<Dialog.Body>
<Text>
{t("sure-delete-loan-0")}
<strong>
<Code>{delLoanCode}</Code>
</strong>{" "}
{t("sure-delete-loan-1")}
<br />
{t("sure-delete-loan-2")}
</Text>
</Dialog.Body>
<Dialog.Footer>
<Dialog.ActionTrigger asChild>
<Button variant="outline">{t("cancel")}</Button>
</Dialog.ActionTrigger>
<Button
colorPalette="red"
onClick={() => deleteLoan(loan.id)}
>
<strong>{t("delete")}</strong>
</Button>
</Dialog.Footer>
<Dialog.CloseTrigger asChild>
<CloseButton size="sm" />
</Dialog.CloseTrigger>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
</Table.Cell>
</Table.Row> </Table.Row>
))} </Table.Header>
</Table.Body> <Table.Body>
</Table.Root> {loans.map((loan) => (
<Table.Row key={loan.id}>
<Table.Cell>
<Text title={loan.loan_code}>
<Code variant="solid">{`${loan.loan_code}`}</Code>
</Text>
</Table.Cell>
<Table.Cell>{formatDate(loan.start_date)}</Table.Cell>
<Table.Cell>{formatDate(loan.end_date)}</Table.Cell>
<Table.Cell>
<Text>
{Array.isArray(loan.loaned_items_name)
? loan.loaned_items_name.join(", ")
: "-"}
</Text>
</Table.Cell>
<Table.Cell>{formatDate(loan.take_date)}</Table.Cell>
<Table.Cell>{formatDate(loan.returned_date)}</Table.Cell>
<Table.Cell>{loan.note}</Table.Cell>
<Table.Cell>
<Dialog.Root role="alertdialog">
<Dialog.Trigger asChild>
<Button
onClick={() => setDelLoanCode(loan.loan_code)}
aria-label="Ausleihe löschen"
style={{
display: "inline-flex",
alignItems: "center",
}}
>
<Trash2 />
</Button>
</Dialog.Trigger>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{t("sure")}</Dialog.Title>
</Dialog.Header>
<Dialog.Body>
<Text>
{t("sure-delete-loan-0")}
<strong>
<Code>{delLoanCode}</Code>
</strong>{" "}
{t("sure-delete-loan-1")}
<br />
{t("sure-delete-loan-2")}
</Text>
</Dialog.Body>
<Dialog.Footer>
<Dialog.ActionTrigger asChild>
<Button variant="outline">
{t("cancel")}
</Button>
</Dialog.ActionTrigger>
<Button
colorPalette="red"
onClick={() => deleteLoan(loan.id)}
>
<strong>{t("delete")}</strong>
</Button>
</Dialog.Footer>
<Dialog.CloseTrigger asChild>
<CloseButton size="sm" />
</Dialog.CloseTrigger>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Box>
)} )}
</Container> </Container>
</> </>

View File

@@ -3,6 +3,9 @@ import { useContext } from "react";
export interface User { export interface User {
username: string; username: string;
is_admin: boolean;
first_name: string;
last_name: string;
role: number; role: number;
} }
@@ -12,7 +15,7 @@ export function useUserContext() {
const user = useContext(UserContext); const user = useContext(UserContext);
if (user === undefined) { if (user === undefined) {
throw new Error("useUserContext must be used with a UserContext") throw new Error("useUserContext must be used with a UserContext");
} }
return user; return user;

View File

@@ -6,7 +6,7 @@ export const getBorrowableItems = async (
endDate: string endDate: string
) => { ) => {
try { try {
const response = await fetch(`${API_BASE}/api/borrowableItems`, { const response = await fetch(`${API_BASE}/api/loans/borrowable-items`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`, Authorization: `Bearer ${Cookies.get("token") || ""}`,
@@ -47,15 +47,16 @@ export const getBorrowableItems = async (
export const createLoan = async ( export const createLoan = async (
itemIds: number[], itemIds: number[],
startDate: string, startDate: string,
endDate: string endDate: string,
note: string | null
) => { ) => {
const response = await fetch(`${API_BASE}/api/createLoan`, { const response = await fetch(`${API_BASE}/api/loans/createLoan`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token") || ""}`, Authorization: `Bearer ${Cookies.get("token") || ""}`,
}, },
body: JSON.stringify({ items: itemIds, startDate, endDate }), body: JSON.stringify({ items: itemIds, startDate, endDate, note }),
}); });
if (!response.ok) { if (!response.ok) {

View File

@@ -60,5 +60,17 @@
"sure-delete-loan-2": "Für den Admin bleibt sie weiterhin sichtbar.", "sure-delete-loan-2": "Für den Admin bleibt sie weiterhin sichtbar.",
"delete": "Löschen", "delete": "Löschen",
"change-language": "Sprache ändern", "change-language": "Sprache ändern",
"timezone-info": "Die angezeigten Daten und Uhrzeiten werden in deutscher Zeitzone dargestellt und müssen auch so eingegeben werden." "timezone-info": "Die angezeigten Daten und Uhrzeiten werden in deutscher Zeitzone dargestellt und müssen auch so eingegeben werden.",
"optional-note": "Optionale Notiz",
"note": "Notiz",
"user-info-desc": "Hier können Sie Ihre persönlichen Informationen einsehen und ändern.",
"role": "Rolle",
"admin-status": "Admin-Status",
"first-name": "Vorname",
"last-name": "Nachname",
"app-title": "Ausleihsystem",
"last-borrowed-person": "Zuletzt ausgeliehen von",
"currently-borrowed-by": "Derzeit ausgeliehen von",
"back": "Zurückgehen",
"landingpage": "Übersichtsseite"
} }

View File

@@ -60,5 +60,17 @@
"sure-delete-loan-2": "It will remain visible to the admin.", "sure-delete-loan-2": "It will remain visible to the admin.",
"delete": "Delete", "delete": "Delete",
"change-language": "Change language", "change-language": "Change language",
"timezone-info": "The displayed dates and times are shown in Berlin timezone and must also be entered as such." "timezone-info": "The displayed dates and times are shown in Berlin timezone and must also be entered as such.",
"optional-note": "Optional note",
"note": "Note",
"user-info-desc": "Here you can view and edit your personal information.",
"role": "Role",
"admin-status": "Admin status",
"first-name": "First name",
"last-name": "Last name",
"app-title": "Borrow System",
"last-borrowed-person": "Last borrowed by",
"currently-borrowed-by": "Currently borrowed by",
"back": "Go back",
"landingpage": "Overview page"
} }

View File

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

18
admin/nginx.conf Normal file
View File

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

View File

@@ -3,11 +3,7 @@ import { useEffect } from "react";
import Dashboard from "./Dashboard"; import Dashboard from "./Dashboard";
import Login from "./Login"; import Login from "./Login";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { API_BASE } from "@/config/api.config";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
const Layout: React.FC = () => { const Layout: React.FC = () => {
const [isLoggedIn, setIsLoggedIn] = useState(false); const [isLoggedIn, setIsLoggedIn] = useState(false);
@@ -15,12 +11,15 @@ const Layout: React.FC = () => {
useEffect(() => { useEffect(() => {
if (Cookies.get("token")) { if (Cookies.get("token")) {
const verifyToken = async () => { const verifyToken = async () => {
const response = await fetch(`${API_BASE}/api/verifyToken`, { const response = await fetch(
method: "GET", `${API_BASE}/api/admin/user-mgmt/verify-token`,
headers: { {
Authorization: `Bearer ${Cookies.get("token")}`, method: "GET",
}, headers: {
}); Authorization: `Bearer ${Cookies.get("token")}`,
},
}
);
if (response.ok) { if (response.ok) {
setIsLoggedIn(true); setIsLoggedIn(true);
} else { } else {

View File

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

View File

@@ -17,17 +17,14 @@ import { useState, useEffect } from "react";
import { deleteAPKey } from "@/utils/userActions"; import { deleteAPKey } from "@/utils/userActions";
import AddAPIKey from "./AddAPIKey"; import AddAPIKey from "./AddAPIKey";
import { formatDateTime } from "@/utils/userFuncs"; import { formatDateTime } from "@/utils/userFuncs";
import { API_BASE } from "@/config/api.config";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
type Items = { type Items = {
id: number; id: number;
apiKey: string; api_key: string;
user: string; entry_name: string;
entry_created_at: string; entry_created_at: string;
last_used_at: string | null;
}; };
const APIKeyTable: React.FC = () => { const APIKeyTable: React.FC = () => {
@@ -56,13 +53,17 @@ const APIKeyTable: React.FC = () => {
const fetchData = async () => { const fetchData = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await fetch(`${API_BASE}/api/apiKeys`, { const response = await fetch(
method: "GET", `${API_BASE}/api/admin/api-data/get-api-keys`,
headers: { {
Authorization: `Bearer ${Cookies.get("token")}`, method: "GET",
}, headers: {
}); Authorization: `Bearer ${Cookies.get("token")}`,
},
}
);
const data = await response.json(); const data = await response.json();
console.log(data);
return data; return data;
} catch (error) { } catch (error) {
setError("error", "Failed to fetch items", "There is an error"); setError("error", "Failed to fetch items", "There is an error");
@@ -123,8 +124,8 @@ const APIKeyTable: React.FC = () => {
</HStack> </HStack>
{/* End action toolbar */} {/* End action toolbar */}
<Heading marginBottom={4} size="md"> <Heading marginBottom={4} size="2xl">
Gegenstände API Keys
</Heading> </Heading>
{isError && ( {isError && (
<MyAlert <MyAlert
@@ -149,39 +150,55 @@ const APIKeyTable: React.FC = () => {
/> />
)} )}
<Table.Root size="sm" striped> <Table.Root
size="sm"
striped
w="100%"
// table-layout: auto => Spaltenbreite nach Content; volle Breite nutzen
style={{ tableLayout: "auto" }}
>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.ColumnHeader> <Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>#</strong> <strong>#</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader> <Table.ColumnHeader>
<strong>API Key</strong> <strong>API Key</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader> <Table.ColumnHeader>
<strong>Benutzer</strong> <strong>Name</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader> <Table.ColumnHeader whiteSpace="nowrap">
<strong>Eintrag erstellt am</strong> <strong>Eintrag erstellt am</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader> <Table.ColumnHeader whiteSpace="nowrap">
<strong>Zuletzt benutzt am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Aktionen</strong> <strong>Aktionen</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
<Table.Body> <Table.Body>
{items.map((apiKey) => ( {items.map((item) => (
<Table.Row key={apiKey.id}> <Table.Row key={item.id}>
<Table.Cell>{apiKey.id}</Table.Cell> <Table.Cell whiteSpace="nowrap">{item.id}</Table.Cell>
<Table.Cell>{apiKey.apiKey}</Table.Cell> <Table.Cell fontFamily="mono">{item.api_key}</Table.Cell>
<Table.Cell>{apiKey.user}</Table.Cell> <Table.Cell>{item.entry_name}</Table.Cell>
<Table.Cell>{formatDateTime(apiKey.entry_created_at)}</Table.Cell> <Table.Cell whiteSpace="nowrap">
<Table.Cell> {formatDateTime(item.entry_created_at)}
</Table.Cell>
<Table.Cell whiteSpace="nowrap">
{!item.last_used_at
? "Nie benutzt"
: formatDateTime(item.last_used_at)}
</Table.Cell>
<Table.Cell whiteSpace="nowrap">
<Button <Button
onClick={() => onClick={() =>
deleteAPKey(apiKey.id).then((response) => { deleteAPKey(item.id).then((response) => {
if (response.success) { if (response.success) {
setItems(items.filter((i) => i.id !== apiKey.id)); setItems(items.filter((i) => i.id !== item.id));
setError( setError(
"success", "success",
"Gegenstand gelöscht", "Gegenstand gelöscht",

View File

@@ -1,6 +1,15 @@
import React from "react"; import React from "react";
import { Button, Card, Field, Input, Stack } from "@chakra-ui/react"; import {
Button,
Card,
Field,
Input,
Stack,
InputGroup,
Span,
} from "@chakra-ui/react";
import { createAPIentry } from "@/utils/userActions"; import { createAPIentry } from "@/utils/userActions";
import { useState } from "react";
type AddAPIKeyProps = { type AddAPIKeyProps = {
onClose: () => void; onClose: () => void;
@@ -12,6 +21,8 @@ type AddAPIKeyProps = {
}; };
const AddAPIKey: React.FC<AddAPIKeyProps> = ({ onClose, alert }) => { const AddAPIKey: React.FC<AddAPIKeyProps> = ({ onClose, alert }) => {
const [value, setValue] = useState("");
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<Card.Root maxW="sm"> <Card.Root maxW="sm">
@@ -23,13 +34,26 @@ const AddAPIKey: React.FC<AddAPIKeyProps> = ({ onClose, alert }) => {
</Card.Header> </Card.Header>
<Card.Body> <Card.Body>
<Stack gap="4" w="full"> <Stack gap="4" w="full">
<InputGroup
endElement={
<Span color="fg.muted" textStyle="xs">
{value.length} / {8}
</Span>
}
>
<Input
placeholder="Er muss 8 zahlen lang sein"
value={value}
id="apiKey"
maxLength={8}
onChange={(e) => {
setValue(e.currentTarget.value.slice(0, 8));
}}
/>
</InputGroup>
<Field.Root> <Field.Root>
<Field.Label>API key</Field.Label> <Field.Label>Name</Field.Label>
<Input type="number" id="apiKey" /> <Input id="name" type="text" />
</Field.Root>
<Field.Root>
<Field.Label>Benutzer</Field.Label>
<Input id="user" type="text" />
</Field.Root> </Field.Root>
</Stack> </Stack>
</Card.Body> </Card.Body>
@@ -44,14 +68,14 @@ const AddAPIKey: React.FC<AddAPIKeyProps> = ({ onClose, alert }) => {
( (
document.getElementById("apiKey") as HTMLInputElement document.getElementById("apiKey") as HTMLInputElement
)?.value.trim() || ""; )?.value.trim() || "";
const user = const name =
( (
document.getElementById("user") as HTMLInputElement document.getElementById("name") as HTMLInputElement
)?.value.trim() || ""; )?.value.trim() || "";
if (!apiKey || !user) return; if (!apiKey || !name) return;
const res = await createAPIentry(apiKey, user); const res = await createAPIentry(apiKey, name);
if (res.success) { if (res.success) {
alert( alert(
"success", "success",

View File

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

View File

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

View File

@@ -30,18 +30,19 @@ import {
} from "@/utils/userActions"; } from "@/utils/userActions";
import AddItemForm from "./AddItemForm"; import AddItemForm from "./AddItemForm";
import { formatDateTime } from "@/utils/userFuncs"; import { formatDateTime } from "@/utils/userFuncs";
import { API_BASE } from "@/config/api.config";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
type Items = { type Items = {
id: number; id: number;
item_name: string; item_name: string;
can_borrow_role: string; can_borrow_role: string;
inSafe: boolean; in_safe: boolean;
safe_nr: string;
door_key: string;
entry_created_at: string; entry_created_at: string;
entry_updated_at: string;
last_borrowed_person: string | null;
currently_borrowing: string | null;
}; };
const ItemTable: React.FC = () => { const ItemTable: React.FC = () => {
@@ -66,6 +67,18 @@ const ItemTable: React.FC = () => {
); );
}; };
const handleLockerNumberChange = (id: number, value: string) => {
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, safe_nr: value } : it))
);
};
const handleDoorKeyChange = (id: number, value: string) => {
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, door_key: value } : it))
);
};
const setError = ( const setError = (
status: "error" | "success", status: "error" | "success",
message: string, message: string,
@@ -82,12 +95,15 @@ const ItemTable: React.FC = () => {
const fetchData = async () => { const fetchData = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await fetch(`${API_BASE}/api/allItems`, { const response = await fetch(
method: "GET", `${API_BASE}/api/admin/item-data/all-items`,
headers: { {
Authorization: `Bearer ${Cookies.get("token")}`, method: "GET",
}, headers: {
}); Authorization: `Bearer ${Cookies.get("token")}`,
},
}
);
const data = await response.json(); const data = await response.json();
return data; return data;
} catch (error) { } catch (error) {
@@ -175,136 +191,185 @@ const ItemTable: React.FC = () => {
/> />
)} )}
<Table.Root size="sm" striped> {/* make table fill available width, like UserTable */}
<Table.Header> {!isLoading && (
<Table.Row> <Table.Root size="sm" striped w="100%" style={{ tableLayout: "auto" }}>
<Table.ColumnHeader> <Table.Header>
<strong>#</strong> <Table.Row>
</Table.ColumnHeader> <Table.ColumnHeader>
<Table.ColumnHeader> <strong>#</strong>
<strong>Gegenstand</strong> </Table.ColumnHeader>
</Table.ColumnHeader> <Table.ColumnHeader>
<Table.ColumnHeader> <strong>Gegenstand</strong>
<strong>Ausleih Berechtigung</strong> </Table.ColumnHeader>
</Table.ColumnHeader> <Table.ColumnHeader>
<Table.ColumnHeader> <strong>Ausleih Berechtigung</strong>
<strong>Im Schließfach</strong> </Table.ColumnHeader>
</Table.ColumnHeader> <Table.ColumnHeader>
<Table.ColumnHeader> <strong>Im Schließfach</strong>
<strong>Eintrag erstellt am</strong> </Table.ColumnHeader>
</Table.ColumnHeader> <Table.ColumnHeader>
<Table.ColumnHeader> <strong>Schließfachnummer</strong>
<strong>Aktionen</strong> </Table.ColumnHeader>
</Table.ColumnHeader> <Table.ColumnHeader>
</Table.Row> <strong>Schlüssel</strong>
</Table.Header> </Table.ColumnHeader>
<Table.Body> <Table.ColumnHeader>
{items.map((item) => ( <strong>Eintrag erstellt am</strong>
<Table.Row key={item.id}> </Table.ColumnHeader>
<Table.Cell>{item.id}</Table.Cell> <Table.ColumnHeader>
<Table.Cell> <strong>Eintrag aktualisiert am</strong>
<Input </Table.ColumnHeader>
onChange={(e) => <Table.ColumnHeader>
handleItemNameChange(item.id, e.target.value) <strong>LaP *</strong>
} </Table.ColumnHeader>
value={item.item_name} <Table.ColumnHeader>
/> <strong>Dav **</strong>
</Table.Cell> </Table.ColumnHeader>
<Table.Cell> <Table.ColumnHeader>
<Input <strong>Aktionen</strong>
onChange={(e) => </Table.ColumnHeader>
handleCanBorrowRoleChange(item.id, e.target.value)
}
value={item.can_borrow_role}
/>
</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
changeSafeState(item.id).then(() => setReload(!reload))
}
size="xs"
rounded="full"
px={3}
py={1}
gap={2}
variant="ghost"
color={item.inSafe ? "green.600" : "red.600"}
borderWidth="1px"
borderColor={item.inSafe ? "green.300" : "red.300"}
_hover={{
bg: item.inSafe ? "green.50" : "red.50",
borderColor: item.inSafe ? "green.400" : "red.400",
transform: "translateY(-1px)",
shadow: "sm",
}}
_active={{ transform: "translateY(0)" }}
aria-label={
item.inSafe ? "Mark as not in safe" : "Mark as in safe"
}
>
<Icon
as={item.inSafe ? CheckCircle2 : XCircle}
boxSize={3.5}
mr={2}
/>
<Text as="span" fontSize="xs" fontWeight="semibold">
{item.inSafe ? "Yes" : "No"}
</Text>
</Button>
</Table.Cell>
<Table.Cell>{formatDateTime(item.entry_created_at)}</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
handleEditItems(
item.id,
item.item_name,
item.can_borrow_role
).then((response) => {
if (response.success) {
setError(
"success",
"Gegenstand erfolgreich bearbeitet!",
"Gegenstand " +
'"' +
item.item_name +
'" mit ID ' +
item.id +
" bearbeitet."
);
}
})
}
colorPalette="teal"
size="sm"
>
<Save />
</Button>
<Button
onClick={() =>
deleteItem(item.id).then((response) => {
if (response.success) {
setItems(items.filter((i) => i.id !== item.id));
setError(
"success",
"Gegenstand gelöscht",
"Der Gegenstand wurde erfolgreich gelöscht."
);
}
})
}
colorPalette="red"
size="sm"
ml={2}
>
<Trash2 />
</Button>
</Table.Cell>
</Table.Row> </Table.Row>
))} </Table.Header>
</Table.Body> <Table.Body>
</Table.Root> {items.map((item) => (
<Table.Row key={item.id}>
<Table.Cell>{item.id}</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleItemNameChange(item.id, e.target.value)
}
value={item.item_name}
/>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleCanBorrowRoleChange(item.id, e.target.value)
}
value={item.can_borrow_role}
/>
</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
changeSafeState(item.id).then(() => setReload(!reload))
}
size="xs"
rounded="full"
px={3}
py={1}
gap={2}
variant="ghost"
color={item.in_safe ? "green.600" : "red.600"}
borderWidth="1px"
borderColor={item.in_safe ? "green.300" : "red.300"}
_hover={{
bg: item.in_safe ? "green.50" : "red.50",
borderColor: item.in_safe ? "green.400" : "red.400",
transform: "translateY(-1px)",
shadow: "sm",
}}
_active={{ transform: "translateY(0)" }}
aria-label={
item.in_safe ? "Mark as not in safe" : "Mark as in safe"
}
>
<Icon
as={item.in_safe ? CheckCircle2 : XCircle}
boxSize={3.5}
mr={2}
/>
<Text as="span" fontSize="xs" fontWeight="semibold">
{item.in_safe ? "Yes" : "No"}
</Text>
</Button>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleLockerNumberChange(item.id, e.target.value)
}
value={item.safe_nr}
/>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleDoorKeyChange(item.id, e.target.value)
}
value={item.door_key}
/>
</Table.Cell>
<Table.Cell>{formatDateTime(item.entry_created_at)}</Table.Cell>
<Table.Cell>{formatDateTime(item.entry_updated_at)}</Table.Cell>
<Table.Cell>{item.last_borrowed_person}</Table.Cell>
<Table.Cell>{item.currently_borrowing}</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
handleEditItems(
item.id,
item.item_name,
item.safe_nr,
item.door_key,
item.can_borrow_role
).then((response) => {
if (response.success) {
setError(
"success",
"Gegenstand erfolgreich bearbeitet!",
"Gegenstand " +
'"' +
item.item_name +
'" mit ID ' +
item.id +
" bearbeitet."
);
}
})
}
colorPalette="teal"
size="sm"
>
<Save />
</Button>
<Button
onClick={() =>
deleteItem(item.id).then((response) => {
if (response.success) {
setItems(items.filter((i) => i.id !== item.id));
setError(
"success",
"Gegenstand gelöscht",
"Der Gegenstand wurde erfolgreich gelöscht."
);
}
})
}
colorPalette="red"
size="sm"
ml={2}
>
<Trash2 />
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
)}
<Text>* LaP = Letzte ausleihende Person</Text>
<Text>** Dav = Derzeit ausgeliehen von</Text>
</> </>
); );
}; };

View File

@@ -17,11 +17,7 @@ import MyAlert from "./myChakra/MyAlert";
import { formatDateTime } from "@/utils/userFuncs"; import { formatDateTime } from "@/utils/userFuncs";
import { Trash2, RefreshCcwDot } from "lucide-react"; import { Trash2, RefreshCcwDot } from "lucide-react";
import { deleteLoan } from "@/utils/userActions"; import { deleteLoan } from "@/utils/userActions";
import { API_BASE } from "@/config/api.config";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
const LoanTable: React.FC = () => { const LoanTable: React.FC = () => {
const [items, setItems] = useState<Loan[]>([]); const [items, setItems] = useState<Loan[]>([]);
@@ -55,18 +51,22 @@ const LoanTable: React.FC = () => {
created_at: string; created_at: string;
loaned_items_name: string[]; loaned_items_name: string[];
deleted: boolean; deleted: boolean;
note: string;
}; };
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await fetch(`${API_BASE}/api/allLoans`, { const response = await fetch(
method: "GET", `${API_BASE}/api/admin/loan-data/all-loans`,
headers: { {
Authorization: `Bearer ${Cookies.get("token")}`, method: "GET",
}, headers: {
}); Authorization: `Bearer ${Cookies.get("token")}`,
},
}
);
const data = await response.json(); const data = await response.json();
return data; return data;
} catch (error) { } catch (error) {
@@ -161,6 +161,9 @@ const LoanTable: React.FC = () => {
<Table.ColumnHeader> <Table.ColumnHeader>
<strong>Ausgeliehene Artikel</strong> <strong>Ausgeliehene Artikel</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Notiz</strong>
</Table.ColumnHeader>
<Table.ColumnHeader> <Table.ColumnHeader>
<strong>Aktionen</strong> <strong>Aktionen</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
@@ -180,6 +183,7 @@ const LoanTable: React.FC = () => {
<Table.Cell>{formatDateTime(item.returned_date)}</Table.Cell> <Table.Cell>{formatDateTime(item.returned_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.created_at)}</Table.Cell> <Table.Cell>{formatDateTime(item.created_at)}</Table.Cell>
<Table.Cell>{item.loaned_items_name.join(", ")}</Table.Cell> <Table.Cell>{item.loaned_items_name.join(", ")}</Table.Cell>
<Table.Cell>{item.note}</Table.Cell>
<Table.Cell> <Table.Cell>
<Button <Button
onClick={() => onClick={() =>

View File

@@ -10,6 +10,7 @@ import {
HStack, HStack,
IconButton, IconButton,
Heading, Heading,
Switch, // neu
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { Tooltip } from "@/components/ui/tooltip"; import { Tooltip } from "@/components/ui/tooltip";
import { fetchUserData } from "@/utils/fetcher"; import { fetchUserData } from "@/utils/fetcher";
@@ -23,9 +24,13 @@ import ChangePWform from "./ChangePWform";
type User = { type User = {
id: number; id: number;
username: string; username: string;
password: string; first_name: string;
role: string; last_name: string;
email: string;
is_admin: boolean;
role: number;
entry_created_at: string; entry_created_at: string;
entry_updated_at: string;
}; };
const UserTable: React.FC = () => { const UserTable: React.FC = () => {
@@ -52,10 +57,20 @@ const UserTable: React.FC = () => {
setIsError(true); setIsError(true);
}; };
const handleInputChange = (userId: number, field: string, value: string) => { const handleInputChange = (userId: number, field: string, value: any) => {
setUsers((prevUsers) => setUsers((prevUsers) =>
prevUsers.map((user) => prevUsers.map((user) =>
user.id === userId ? { ...user, [field]: value } : user user.id === userId
? {
...user,
[field]:
field === "role"
? Number(value)
: field === "is_admin"
? value === true || value === "true" || value === 1
: value,
}
: user
) )
); );
}; };
@@ -70,7 +85,7 @@ const UserTable: React.FC = () => {
setIsLoading(true); setIsLoading(true);
try { try {
const data = await fetchUserData(); const data = await fetchUserData();
console.log("user api response", data); console.log(data);
if (Array.isArray(data)) { if (Array.isArray(data)) {
setUsers(data); setUsers(data);
} else { } else {
@@ -180,25 +195,45 @@ const UserTable: React.FC = () => {
</VStack> </VStack>
)} )}
{!isLoading && ( {!isLoading && (
<Table.Root size="sm" striped> <Table.Root
size="sm"
striped
w="100%"
style={{ tableLayout: "auto" }} // Spalten nach Content
>
<Table.Header> <Table.Header>
<Table.Row> <Table.Row>
<Table.ColumnHeader> <Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>#</strong> <strong>#</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader> <Table.ColumnHeader>
<strong>Benutzername</strong> <strong>Benutzername</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader> <Table.ColumnHeader>
<strong>Vorname</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Nachname</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>E-Mail</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Admin</strong>
</Table.ColumnHeader>
<Table.ColumnHeader whiteSpace="nowrap">
<strong>Passwort ändern</strong> <strong>Passwort ändern</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader> <Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Rolle</strong> <strong>Rolle</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader> <Table.ColumnHeader whiteSpace="nowrap">
<strong>Eintrag erstellt am</strong> <strong>Eintrag erstellt am</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
<Table.ColumnHeader> <Table.ColumnHeader whiteSpace="nowrap">
<strong>Eintrag aktualisiert am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Aktionen</strong> <strong>Aktionen</strong>
</Table.ColumnHeader> </Table.ColumnHeader>
</Table.Row> </Table.Row>
@@ -206,37 +241,86 @@ const UserTable: React.FC = () => {
<Table.Body> <Table.Body>
{users.map((user) => ( {users.map((user) => (
<Table.Row key={user.id}> <Table.Row key={user.id}>
<Table.Cell>{user.id}</Table.Cell> <Table.Cell whiteSpace="nowrap">{user.id}</Table.Cell>
<Table.Cell>{user.username}</Table.Cell>
<Table.Cell> <Table.Cell>
<Input <Input
size="sm"
value={user.first_name ?? ""}
onChange={(e) => onChange={(e) =>
handleInputChange(user.id, "username", e.target.value) handleInputChange(user.id, "first_name", e.target.value)
} }
value={user.username}
/> />
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<Button onClick={() => handlePasswordChange(user.username)}> <Input
Passwort ändern size="sm"
</Button> value={user.last_name ?? ""}
onChange={(e) =>
handleInputChange(user.id, "last_name", e.target.value)
}
/>
</Table.Cell> </Table.Cell>
<Table.Cell> <Table.Cell>
<Input
type="email"
size="sm"
value={user.email ?? ""}
onChange={(e) =>
handleInputChange(user.id, "email", e.target.value)
}
/>
</Table.Cell>
<Table.Cell whiteSpace="nowrap">
<Switch.Root
size="sm"
checked={!!user.is_admin}
onCheckedChange={(d) =>
handleInputChange(user.id, "is_admin", d.checked)
}
aria-label="Adminrechte umschalten"
>
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
<Switch.HiddenInput />
</Switch.Root>
</Table.Cell>
<Table.Cell whiteSpace="nowrap">
<Button
size="sm"
onClick={() => handlePasswordChange(user.username)}
>
Passwort ändern
</Button>
</Table.Cell>
<Table.Cell whiteSpace="nowrap">
<Input <Input
type="number" type="number"
size="sm"
onChange={(e) => onChange={(e) =>
handleInputChange(user.id, "role", e.target.value) handleInputChange(user.id, "role", e.target.value)
} }
value={user.role} value={user.role}
width="70px"
/> />
</Table.Cell> </Table.Cell>
<Table.Cell>{formatDateTime(user.entry_created_at)}</Table.Cell> <Table.Cell whiteSpace="nowrap">
<Table.Cell> {formatDateTime(user.entry_created_at)}
</Table.Cell>
<Table.Cell whiteSpace="nowrap">
{formatDateTime(user.entry_updated_at)}
</Table.Cell>
<Table.Cell whiteSpace="nowrap">
<Button <Button
onClick={() => onClick={() =>
handleEdit( handleEdit(
user.id, user.id,
user.username, user.first_name,
user.role, user.last_name,
user.email,
user.is_admin,
Number(user.role)
).then((response) => { ).then((response) => {
if (response.success) { if (response.success) {
setError( setError(

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,10 @@
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { API_BASE } from "@/config/api.config";
const API_BASE =
(import.meta as any).env?.VITE_BACKEND_URL ||
import.meta.env.VITE_BACKEND_URL ||
"http://localhost:8002";
export const handleDelete = async (userId: number) => { export const handleDelete = async (userId: number) => {
try { try {
const response = await fetch( const response = await fetch(
`${API_BASE}/api/deleteUser/${userId}`, `${API_BASE}/api/admin/user-data/delete-user/${userId}`,
{ {
method: "DELETE", method: "DELETE",
headers: { headers: {
@@ -28,19 +24,28 @@ export const handleDelete = async (userId: number) => {
export const handleEdit = async ( export const handleEdit = async (
userId: number, userId: number,
username: string, first_name: string,
role: string last_name: string,
email: string,
is_admin: boolean,
role: number
) => { ) => {
try { try {
const response = await fetch( const response = await fetch(
`${API_BASE}/api/editUser/${userId}`, `${API_BASE}/api/admin/user-data/edit-user/${userId}`,
{ {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,
}, },
body: JSON.stringify({ username, role }), body: JSON.stringify({
first_name,
last_name,
role,
email,
is_admin,
}),
} }
); );
if (!response.ok) { if (!response.ok) {
@@ -56,17 +61,32 @@ export const handleEdit = async (
export const createUser = async ( export const createUser = async (
username: string, username: string,
role: number, role: number,
password: string password: string,
first_name: string,
last_name: string,
email: string,
isAdmin: boolean
) => { ) => {
try { try {
const response = await fetch(`${API_BASE}/api/createUser`, { const response = await fetch(
method: "POST", `${API_BASE}/api/admin/user-data/create-user`,
headers: { {
"Content-Type": "application/json", method: "POST",
Authorization: `Bearer ${Cookies.get("token")}`, headers: {
}, "Content-Type": "application/json",
body: JSON.stringify({ username, role, password }), Authorization: `Bearer ${Cookies.get("token")}`,
}); },
body: JSON.stringify({
username,
role,
password,
isAdmin,
email,
first_name,
last_name,
}),
}
);
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to create user"); throw new Error("Failed to create user");
} }
@@ -79,14 +99,17 @@ export const createUser = async (
export const changePW = async (newPassword: string, username: string) => { export const changePW = async (newPassword: string, username: string) => {
try { try {
const response = await fetch(`${API_BASE}/api/changePWadmin`, { const response = await fetch(
method: "POST", `${API_BASE}/api/admin/user-data/change-password`,
headers: { {
"Content-Type": "application/json", method: "POST",
Authorization: `Bearer ${Cookies.get("token")}`, headers: {
}, "Content-Type": "application/json",
body: JSON.stringify({ newPassword, username }), Authorization: `Bearer ${Cookies.get("token")}`,
}); },
body: JSON.stringify({ username, password: newPassword }),
}
);
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to change password"); throw new Error("Failed to change password");
} }
@@ -100,7 +123,7 @@ export const changePW = async (newPassword: string, username: string) => {
export const deleteLoan = async (loanId: number) => { export const deleteLoan = async (loanId: number) => {
try { try {
const response = await fetch( const response = await fetch(
`${API_BASE}/api/deleteLoan/${loanId}`, `${API_BASE}/api/admin/loan-data/delete-loan/${loanId}`,
{ {
method: "DELETE", method: "DELETE",
headers: { headers: {
@@ -121,7 +144,7 @@ export const deleteLoan = async (loanId: number) => {
export const deleteItem = async (itemId: number) => { export const deleteItem = async (itemId: number) => {
try { try {
const response = await fetch( const response = await fetch(
`${API_BASE}/api/deleteItem/${itemId}`, `${API_BASE}/api/admin/item-data/delete-item/${itemId}`,
{ {
method: "DELETE", method: "DELETE",
headers: { headers: {
@@ -141,22 +164,27 @@ export const deleteItem = async (itemId: number) => {
export const createItem = async ( export const createItem = async (
item_name: string, item_name: string,
can_borrow_role: number can_borrow_role: number,
lockerNumber: string | null
) => { ) => {
console.log(JSON.stringify({ item_name, can_borrow_role, lockerNumber }));
try { try {
const response = await fetch(`${API_BASE}/api/createItem`, { const response = await fetch(
method: "POST", `${API_BASE}/api/admin/item-data/create-item`,
headers: { {
"Content-Type": "application/json", method: "POST",
Authorization: `Bearer ${Cookies.get("token")}`, headers: {
}, "Content-Type": "application/json",
body: JSON.stringify({ item_name, can_borrow_role }), Authorization: `Bearer ${Cookies.get("token")}`,
}); },
body: JSON.stringify({ item_name, can_borrow_role, lockerNumber }),
}
);
if (!response.ok) { if (!response.ok) {
return { return {
success: false, success: false,
message: message:
"Fehler beim Erstellen des Gegenstands. Der Name des Gegenstandes darf nicht mehrmals vergeben werden.", "Fehler beim Erstellen des Gegenstands. Der Name des Gegenstandes und die Schließfachnummer dürfen nicht mehrmals vergeben werden.",
}; };
} }
return { success: true }; return { success: true };
@@ -169,17 +197,22 @@ export const createItem = async (
export const handleEditItems = async ( export const handleEditItems = async (
itemId: number, itemId: number,
item_name: string, item_name: string,
safe_nr: string | null,
door_key: string | null,
can_borrow_role: string can_borrow_role: string
) => { ) => {
try { try {
const response = await fetch(`${API_BASE}/api/updateItemByID`, { const response = await fetch(
method: "POST", `${API_BASE}/api/admin/item-data/edit-item/${itemId}`,
headers: { {
"Content-Type": "application/json", method: "POST",
Authorization: `Bearer ${Cookies.get("token")}`, headers: {
}, "Content-Type": "application/json",
body: JSON.stringify({ itemId, item_name, can_borrow_role }), Authorization: `Bearer ${Cookies.get("token")}`,
}); },
body: JSON.stringify({ item_name, safe_nr, door_key, can_borrow_role }),
}
);
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to edit item"); throw new Error("Failed to edit item");
} }
@@ -193,9 +226,9 @@ export const handleEditItems = async (
export const changeSafeState = async (itemId: number) => { export const changeSafeState = async (itemId: number) => {
try { try {
const response = await fetch( const response = await fetch(
`${API_BASE}/api/changeSafeState/${itemId}`, `${API_BASE}/api/admin/item-data/change-safe-state/${itemId}`,
{ {
method: "PUT", method: "POST",
headers: { headers: {
Authorization: `Bearer ${Cookies.get("token")}`, Authorization: `Bearer ${Cookies.get("token")}`,
}, },
@@ -211,16 +244,19 @@ export const changeSafeState = async (itemId: number) => {
} }
}; };
export const createAPIentry = async (apiKey: string, user: string) => { export const createAPIentry = async (apiKey: string, name: string) => {
try { try {
const response = await fetch(`${API_BASE}/api/createAPIentry`, { const response = await fetch(
method: "POST", `${API_BASE}/api/admin/api-data/create-api-key`,
headers: { {
"Content-Type": "application/json", method: "POST",
Authorization: `Bearer ${Cookies.get("token")}`, headers: {
}, "Content-Type": "application/json",
body: JSON.stringify({ apiKey, user }), Authorization: `Bearer ${Cookies.get("token")}`,
}); },
body: JSON.stringify({ apiKey, entryName: name }),
}
);
if (!response.ok) { if (!response.ok) {
return { return {
success: false, success: false,
@@ -238,7 +274,7 @@ export const createAPIentry = async (apiKey: string, user: string) => {
export const deleteAPKey = async (apiKeyId: number) => { export const deleteAPKey = async (apiKeyId: number) => {
try { try {
const response = await fetch( const response = await fetch(
`${API_BASE}/api/deleteAPKey/${apiKeyId}`, `${API_BASE}/api/admin/api-data/delete-api-key/${apiKeyId}`,
{ {
method: "DELETE", method: "DELETE",
headers: { headers: {

View File

@@ -30,7 +30,7 @@
}, },
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"ignoreDeprecations": "6.0" "ignoreDeprecations": "5.0"
}, },
"include": ["src"] "include": ["src"]
} }

View File

@@ -1,12 +0,0 @@
FROM node:20-alpine
WORKDIR /backend
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 8002
CMD ["npm", "start"]

View File

@@ -1,8 +0,0 @@
{
"backend-info": {
"version": "v2.0 (dev)"
},
"frontend-info": {
"version": "v2.0 (dev)"
}
}

1072
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +0,0 @@
{
"name": "backend",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
"express": "^5.1.0",
"jose": "^6.0.12",
"mysql2": "^3.14.3",
"nodemailer": "^7.0.6"
}
}

View File

@@ -1,599 +0,0 @@
import express from "express";
import {
loginFunc,
getItemsFromDatabase,
getLoansFromDatabase,
getUserLoansFromDatabase,
deleteLoanFromDatabase,
getBorrowableItemsFromDatabase,
createLoanInDatabase,
onTake,
loginAdmin,
onReturn,
getAllUsers,
deleteUserID,
handleEdit,
createUser,
getAllLoans,
getAllItems,
deleteItemID,
createItem,
changeUserPassword,
changeUserPasswordFRONTEND,
changeInSafeStateV2,
updateItemByID,
getAllApiKeys,
createAPIentry,
deleteAPKey,
getLoanInfoWithID,
SETdeleteLoanFromDatabase,
} from "../services/database.js";
import { authenticate, generateToken } from "../services/tokenService.js";
const router = express.Router();
import nodemailer from "nodemailer";
import dotenv from "dotenv";
dotenv.config();
// Nice HTML + text templates for the loan email
function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
const itemsList =
Array.isArray(items) && items.length
? `<ul style="margin:4px 0 0 18px; padding:0;">${items
.map(
(i) =>
`<li style="margin:2px 0; color:#111827; line-height:1.3;">${i}</li>`
)
.join("")}</ul>`
: "<span style='color:#111827;'>N/A</span>";
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root { color-scheme: light; supported-color-schemes: light; }
body { margin:0; padding:0; }
/* Mobile stacking */
@media (max-width:480px) {
.outer { width:100% !important; }
.pad-sm { padding:16px !important; }
.w-label { width:120px !important; }
}
/* Dark-mode override safety */
@media (prefers-color-scheme: dark) {
body, table, td, p, a, h1, h2, h3 { background:#ffffff !important; color:#111827 !important; }
.brand-header { background:${brand} !important; color:#ffffff !important; }
a { color:${brand} !important; }
}
</style>
</head>
<body bgcolor="#ffffff" style="background:#ffffff; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; color:#111827; -webkit-text-size-adjust:100%;">
<!-- Preheader (hidden) -->
<div style="display:none; max-height:0; overflow:hidden; opacity:0; mso-hide:all;">
Neue Ausleihe erstellt Übersicht der Buchung.
</div>
<div role="article" aria-roledescription="email" lang="de" style="padding:24px; background:#f2f4f7;">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" class="outer" style="max-width:600px; margin:0 auto; background:#ffffff; border:1px solid #e5e7eb; border-radius:14px; overflow:hidden;">
<tr>
<td class="brand-header" style="padding:22px 26px; background:${brand}; color:#ffffff;">
<h1 style="margin:0; font-size:18px; line-height:1.35; font-weight:600;">Neue Ausleihe erstellt</h1>
</td>
</tr>
<tr>
<td class="pad-sm" style="padding:24px 26px; color:#111827;">
<p style="margin:0 0 14px 0; line-height:1.4;">Es wurde eine neue Ausleihe angelegt. Hier sind die Details:</p>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse; font-size:14px; line-height:1.3; background:#fcfcfd; border:1px solid #e5e7eb; border-radius:10px; overflow:hidden;">
<tbody>
<tr>
<td class="w-label" style="padding:10px 14px; color:#6b7280; width:170px; border-bottom:1px solid #ececec;">Benutzer</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${
user || "N/A"
}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280; vertical-align:top; border-bottom:1px solid #ececec;">Ausgeliehene Gegenstände</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${itemsList}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Startdatum</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
startDate
)}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Enddatum</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
endDate
)}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280;">Erstellt am</td>
<td style="padding:10px 14px; font-weight:600; color:#111827;">${formatDateTime(
createdDate
)}</td>
</tr>
</tbody>
</table>
<p style="margin:22px 0 0 0; font-size:14px;">
<a href="https://admin.insta.the1s.de/api" style="display:inline-block; background:${brand}; color:#ffffff; text-decoration:none; padding:10px 16px; border-radius:6px; font-weight:600; font-size:14px;" target="_blank" rel="noopener noreferrer">
Übersicht öffnen
</a>
</p>
<p style="margin:18px 0 0 0; font-size:12px; color:#6b7280; line-height:1.4;">
Diese E-Mail wurde automatisch vom Ausleihsystem gesendet. Bitte nicht antworten.
</p>
</td>
</tr>
</table>
</div>
</body>
</html>`;
}
function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
const itemsText =
Array.isArray(items) && items.length ? items.join(", ") : "N/A";
return [
"Neue Ausleihe erstellt",
"",
`Benutzer: ${user || "N/A"}`,
`Gegenstände: ${itemsText}`,
`Start: ${formatDateTime(startDate)}`,
`Ende: ${formatDateTime(endDate)}`,
`Erstellt am: ${formatDateTime(createdDate)}`,
].join("\n");
}
function sendMailLoan(user, items, startDate, endDate, createdDate) {
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST,
port: process.env.MAIL_PORT,
secure: true,
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASSWORD,
},
});
(async () => {
const info = await transporter.sendMail({
from: '"Ausleihsystem" <noreply@mcs-medien.de>',
to: process.env.MAIL_SENDEES,
subject: "Eine neue Ausleihe wurde erstellt!",
text: buildLoanEmailText({
user,
items,
startDate,
endDate,
createdDate,
}),
html: buildLoanEmail({ user, items, startDate, endDate, createdDate }),
});
console.log("Message sent:", info.messageId);
})();
console.log("sendMailLoan called");
}
const formatDateTime = (value) => {
if (value == null) return "N/A";
const toOut = (d) => {
if (!(d instanceof Date) || isNaN(d.getTime())) return "N/A";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${dd}.${mm}.${yyyy} ${hh}:${mi} Uhr`;
};
if (value instanceof Date) return toOut(value);
if (typeof value === "number") return toOut(new Date(value));
const s = String(value).trim();
// Direct pattern: "YYYY-MM-DD[ T]HH:mm[:ss]"
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::\d{2})?/);
if (m) {
const [, y, M, d, h, min] = m;
return `${d}.${M}.${y} ${h}:${min} Uhr`;
}
// ISO or other parseable formats
const dObj = new Date(s);
if (!isNaN(dObj.getTime())) return toOut(dObj);
return "N/A";
};
router.post("/login", async (req, res) => {
const result = await loginFunc(req.body.username, req.body.password);
if (result.success) {
const token = await generateToken({
username: result.data.username,
role: result.data.role,
});
res.status(200).json({ message: "Login successful", token });
} else {
res.status(401).json({ message: "Invalid credentials" });
}
});
router.get("/items", authenticate, async (req, res) => {
const result = await getItemsFromDatabase(req.user.role);
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch items" });
}
});
router.get("/loans", authenticate, async (req, res) => {
const result = await getLoansFromDatabase();
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch loans" });
}
});
router.get("/userLoans", authenticate, async (req, res) => {
const result = await getUserLoansFromDatabase(req.user.username);
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch user loans" });
}
});
router.delete("/deleteLoan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await deleteLoanFromDatabase(loanId);
if (result.success) {
res.status(200).json({ message: "Loan deleted successfully" });
} else {
res.status(500).json({ message: "Failed to delete loan" });
}
});
router.delete("/SETdeleteLoan/: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.post("/borrowableItems", authenticate, async (req, res) => {
const { startDate, endDate } = req.body || {};
if (!startDate || !endDate) {
return res
.status(400)
.json({ message: "startDate and endDate are required" });
}
const result = await getBorrowableItemsFromDatabase(
startDate,
endDate,
req.user.role
);
if (result.success) {
// return the array directly for consistency with /items
return res.status(200).json(result.data);
} else {
return res
.status(500)
.json({ message: "Failed to fetch borrowable items" });
}
});
router.post("/takeLoan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await onTake(loanId);
if (result.success) {
res.status(200).json({ message: "Loan taken successfully" });
} else {
res.status(500).json({ message: "Failed to take loan" });
}
});
router.post("/returnLoan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await onReturn(loanId);
if (result.success) {
res.status(200).json({ message: "Loan returned successfully" });
} else {
res.status(500).json({ message: "Failed to return loan" });
}
});
router.post("/createLoan", authenticate, async (req, res) => {
try {
const { items, startDate, endDate } = req.body || {};
if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ message: "Items array is required" });
}
// If dates are not provided, default to now .. +7 days
const start =
startDate ?? new Date().toISOString().slice(0, 19).replace("T", " ");
const end =
endDate ??
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 19)
.replace("T", " ");
// Coerce item IDs to numbers and filter invalids
const itemIds = items
.map((v) => Number(v))
.filter((n) => Number.isFinite(n));
if (itemIds.length === 0) {
return res.status(400).json({ message: "No valid item IDs provided" });
}
const result = await createLoanInDatabase(
req.user.username,
start,
end,
itemIds
);
if (result.success) {
const mailInfo = await getLoanInfoWithID(result.data.id);
console.log(mailInfo);
sendMailLoan(
mailInfo.data.username,
mailInfo.data.loaned_items_name,
mailInfo.data.start_date,
mailInfo.data.end_date,
mailInfo.data.created_at
);
return res.status(201).json({
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" });
}
});
router.post("/changePassword", authenticate, async (req, res) => {
const { oldPassword, newPassword } = req.body || {};
const username = req.user.username;
const result = await changeUserPasswordFRONTEND(
username,
oldPassword,
newPassword
);
if (result.success) {
res.status(200).json({ message: "Password changed successfully" });
} else {
res.status(500).json({ message: "Failed to change password" });
}
});
// Admin panel functions
router.post("/loginAdmin", async (req, res) => {
const { username, password } = req.body || {};
if (!username || !password) {
return res
.status(400)
.json({ message: "Username and password are required" });
}
const result = await loginAdmin(username, password);
if (result.success) {
const token = await generateToken({
username: result.data.username,
role: result.data.role,
});
return res.status(200).json({
message: "Login successful",
first_name: result.data.first_name,
token,
});
}
return res.status(401).json({ message: "Invalid credentials" });
});
router.get("/allUsers", authenticate, async (req, res) => {
const result = await getAllUsers();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch users" });
});
router.delete("/deleteUser/:id", authenticate, async (req, res) => {
const userId = req.params.id;
const result = await deleteUserID(userId);
if (result.success) {
return res.status(200).json({ message: "User deleted successfully" });
}
return res.status(500).json({ message: "Failed to delete user" });
});
router.get("/verifyToken", authenticate, async (req, res) => {
res.status(200).json({ message: "Token is valid", user: req.user });
});
router.post("/editUser/:id", authenticate, async (req, res) => {
const userId = req.params.id;
const { username, role } = req.body || {};
const result = await handleEdit(userId, username, role);
if (result.success) {
return res.status(200).json({ message: "User edited successfully" });
}
return res.status(500).json({ message: "Failed to edit user" });
});
router.post("/createUser", authenticate, async (req, res) => {
const { username, role, password } = req.body || {};
const result = await createUser(username, role, password);
if (result.success) {
return res.status(201).json({ message: "User created successfully" });
}
return res.status(500).json({ message: "Failed to create user" });
});
router.get("/allLoans", authenticate, async (req, res) => {
const result = await getAllLoans();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch loans" });
});
router.get("/allItems", authenticate, async (req, res) => {
const result = await getAllItems();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch items" });
});
router.delete("/deleteItem/:id", authenticate, async (req, res) => {
const itemId = req.params.id;
const result = await deleteItemID(itemId);
if (result.success) {
return res.status(200).json({ message: "Item deleted successfully" });
}
return res.status(500).json({ message: "Failed to delete item" });
});
router.post("/createItem", authenticate, async (req, res) => {
const { item_name, can_borrow_role } = req.body || {};
const result = await createItem(item_name, can_borrow_role);
if (result.success) {
return res.status(201).json({ message: "Item created successfully" });
}
return res.status(500).json({ message: "Failed to create item" });
});
router.post("/changePWadmin", authenticate, async (req, res) => {
const newPassword = req.body.newPassword;
if (!newPassword) {
return res.status(400).json({ message: "New password is required" });
}
const result = await changeUserPassword(req.body.username, newPassword);
if (result.success) {
return res.status(200).json({ message: "Password changed successfully" });
}
return res.status(500).json({ message: "Failed to change password" });
});
router.post("/updateItemByID", authenticate, async (req, res) => {
const role = req.body.can_borrow_role;
const itemId = req.body.itemId;
const item_name = req.body.item_name;
const result = await updateItemByID(itemId, item_name, role);
if (result.success) {
return res.status(200).json({ message: "Item updated successfully" });
}
return res.status(500).json({ message: "Failed to update item" });
});
router.put("/changeSafeState/:itemId", authenticate, async (req, res) => {
const itemId = req.params.itemId;
const result = await changeInSafeStateV2(itemId);
if (result.success) {
return res
.status(200)
.json({ message: "Item safe state updated successfully" });
}
return res.status(500).json({ message: "Failed to update item safe state" });
});
router.get("/apiKeys", authenticate, async (req, res) => {
const result = await getAllApiKeys();
if (result.success) {
return res.status(200).json(result.data);
}
return res.status(500).json({ message: "Failed to fetch API keys" });
});
router.delete("/deleteAPKey/:id", authenticate, async (req, res) => {
const apiKeyId = req.params.id;
const result = await deleteAPKey(apiKeyId);
if (result.success) {
return res.status(200).json({ message: "API key deleted successfully" });
}
return res.status(500).json({ message: "Failed to delete API key" });
});
router.post("/createAPIentry", authenticate, async (req, res) => {
const apiKey = req.body.apiKey;
const user = req.body.user;
if (!apiKey || !user) {
return res.status(400).json({ message: "API key and user are required" });
}
// Ensure apiKey is a number
const apiKeyNum = Number(apiKey);
if (!Number.isFinite(apiKeyNum)) {
return res.status(400).json({ message: "API key must be a number" });
}
const result = await createAPIentry(apiKeyNum, user);
if (result.success) {
return res.status(201).json({ message: "API key created successfully" });
}
if (result.code === "DUPLICATE") {
return res.status(409).json({ message: "API key already exists" });
}
return res.status(500).json({ message: "Failed to create API key" });
});
router.get("/apiKeys/validate/:key", async (req, res) => {
try {
const rawKey = req.params.key;
const result = await getAllApiKeys();
if (!result.success || !Array.isArray(result.data)) {
return res.status(500).json({ valid: false });
}
const isValid = result.data.some((entry) => {
const val = String(
entry?.key ?? entry?.apiKey ?? entry?.api_key ?? entry
);
return val === String(rawKey);
});
return res.status(200).json({ valid: isValid });
} catch (err) {
console.error("validate api key error:", err);
return res.status(500).json({ valid: false });
}
});
export default router;

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>backend</title>
</head>
<body>
backend
</body>
</html>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
import mysql from "mysql2";
import dotenv from "dotenv";
dotenv.config();
const pool = mysql
.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
.promise();
export const getAllApiKeys = async () => {
const [rows] = await pool.query("SELECT * FROM apiKeys");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const createAPIentry = async (apiKey, entryName) => {
const [result] = await pool.query(
"INSERT INTO apiKeys (api_key, entry_name) VALUES (?, ?)",
[apiKey, entryName]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const deleteAPKey = async (apiKeyId) => {
const [result] = await pool.query("DELETE FROM apiKeys WHERE id = ?", [
apiKeyId,
]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};

View File

@@ -0,0 +1,82 @@
import mysql from "mysql2";
import dotenv from "dotenv";
dotenv.config();
const pool = mysql
.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
.promise();
export const getAllItems = async () => {
const [result] = await pool.query("SELECT * FROM items");
if (result.length > 0) return { success: true, data: result };
return { success: false };
};
export const deleteItemById = async (itemId) => {
const [result] = await pool.query("DELETE FROM items WHERE id = ?", [itemId]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const createItem = async (item_name, can_borrow_role, lockerNumber) => {
const [result] = await pool.query(
"INSERT INTO items (item_name, can_borrow_role, in_safe, safe_nr) VALUES (?, ?, ?, ?)",
[item_name, can_borrow_role, true, lockerNumber]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const editItemById = async (
itemId,
item_name,
can_borrow_role,
safe_nr,
door_key
) => {
let newSafeNr;
if (safe_nr === null || safe_nr === "") {
newSafeNr = null;
} else {
newSafeNr = safe_nr;
}
const [result] = await pool.query(
"UPDATE items SET item_name = ?, can_borrow_role = ?, safe_nr = ?, door_key = ?, entry_updated_at = NOW() WHERE id = ?",
[item_name, can_borrow_role, newSafeNr, door_key, itemId]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const changeSafeState = async (itemId) => {
const currentState = await pool.query(
"SELECT in_safe FROM items WHERE id = ?",
[itemId]
);
if (currentState[0].length === 0) {
return { success: false };
}
if (currentState[0][0].in_safe) {
const [result] = await pool.query(
"UPDATE items SET in_safe = false WHERE id = ?",
[itemId]
);
if (result.affectedRows > 0) return { success: true };
}
if (!currentState[0][0].in_safe) {
const [result] = await pool.query(
"UPDATE items SET in_safe = true WHERE id = ?",
[itemId]
);
if (result.affectedRows > 0) return { success: true };
}
return { success: false };
};

View File

@@ -0,0 +1,23 @@
import mysql from "mysql2";
import dotenv from "dotenv";
dotenv.config();
const pool = mysql
.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
.promise();
export const getAllLoans = async () => {
const [rows] = await pool.query("SELECT * FROM loans");
return { success: true, data: rows };
};
export const deleteLoanById = async (loanId) => {
const [result] = await pool.query("DELETE FROM loans WHERE id = ?", [loanId]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};

View File

@@ -0,0 +1,79 @@
import mysql from "mysql2";
import dotenv from "dotenv";
dotenv.config();
const pool = mysql
.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
.promise();
export const createUser = async (
username,
role,
password,
isAdmin,
email,
first_name,
last_name
) => {
const [result] = await pool.query(
"INSERT INTO users (username, role, password, is_admin, email, first_name, last_name) VALUES (?, ?, ?, ?, ?, ?, ?)",
[username, role, password, isAdmin, email, first_name, last_name]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const deleteUserById = async (userId) => {
const [result] = await pool.query("DELETE FROM users WHERE id = ?", [userId]);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const changePassword = async (userId, newPassword) => {
const [result] = await pool.query(
"UPDATE users SET password = ?, entry_updated_at = NOW() WHERE id = ?",
[newPassword, userId]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const editUserById = async (
userId,
first_name,
last_name,
role,
email,
is_admin
) => {
const [result] = await pool.query(
"UPDATE users SET first_name = ?, last_name = ?, role = ?, email = ?, is_admin = ?, entry_updated_at = NOW() WHERE id = ?",
[first_name, last_name, role, email, is_admin, userId]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const getAllUsers = async () => {
const [result] = await pool.query(
"SELECT id, username, first_name, last_name, role, email, is_admin, entry_created_at, entry_updated_at FROM users"
);
if (result.length > 0) return { success: true, data: result };
return { success: false };
};
export const getUserById = async (userId) => {
const [rows] = await pool.query(
"SELECT id, username, first_name, last_name, role, email, is_admin FROM users WHERE id = ?",
[userId]
);
if (rows.length === 0) {
return { success: false };
}
return { success: true, data: rows[0] };
};

View File

@@ -0,0 +1,47 @@
import mysql from "mysql2";
import dotenv from "dotenv";
dotenv.config();
const pool = mysql
.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
.promise();
export const loginAdmin = async (username, password) => {
const [rows] = await pool.query(
"SELECT id, username, first_name, last_name, role, is_admin FROM users WHERE username = ? AND password = ?",
[username, password]
);
if (rows.length === 0) {
return { success: false, reason: "invalid_credentials" };
}
const user = rows[0];
if (!user.is_admin) {
return { success: false, reason: "not_admin" };
}
return { success: true, data: user };
};
export const executeQuery = async (query, password, username) => {
let verified = false;
const [user] = await pool.query(
"SELECT * FROM users WHERE username = ? AND password = ?",
[username, password]
);
if (user.length > 0 && user[0].is_admin) {
verified = true;
}
if (!verified) {
return { success: false, message: "Unauthorized" };
}
const [result] = await pool.query(`${query}`);
return { success: true, data: result };
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,135 @@
import mysql from "mysql2";
import dotenv from "dotenv";
dotenv.config();
const pool = mysql
.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
.promise();
export const getItemsFromDatabaseV2 = async () => {
const [rows] = await pool.query("SELECT * FROM items;");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const getLoanByCodeV2 = async (loan_code) => {
const [result] = await pool.query(
"SELECT username, returned_date, take_date, lockers FROM loans WHERE loan_code = ?;",
[loan_code]
);
if (result.length > 0) {
return { success: true, data: result[0] };
}
return { success: false };
};
export const changeInSafeStateV2 = async (itemId) => {
const [result] = await pool.query(
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
[itemId]
);
if (result.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const setReturnDateV2 = async (loanCode) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode]
);
const [owner] = await pool.query(
"SELECT username FROM loans WHERE loan_code = ?",
[loanCode]
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET in_safe = 1, currently_borrowing = NULL, last_borrowed_person = (?) WHERE id IN (?)",
[owner[0].username, itemIds]
);
const [result] = await pool.query(
"UPDATE loans SET returned_date = NOW() WHERE loan_code = ?",
[loanCode]
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const setTakeDateV2 = async (loanCode) => {
const [items] = await pool.query(
"SELECT loaned_items_id FROM loans WHERE loan_code = ?",
[loanCode]
);
const [owner] = await pool.query(
"SELECT username FROM loans WHERE loan_code = ?",
[loanCode]
);
if (items.length === 0) return { success: false };
const itemIds = Array.isArray(items[0].loaned_items_id)
? items[0].loaned_items_id
: JSON.parse(items[0].loaned_items_id || "[]");
const [setItemStates] = await pool.query(
"UPDATE items SET in_safe = 0, currently_borrowing = (?) WHERE id IN (?)",
[owner[0].username, itemIds]
);
const [result] = await pool.query(
"UPDATE loans SET take_date = NOW() WHERE loan_code = ?",
[loanCode]
);
if (result.affectedRows > 0 && setItemStates.affectedRows > 0) {
return { success: true };
}
return { success: false };
};
export const getAllLoansV2 = async () => {
const [result] = await pool.query("SELECT * FROM loans;");
if (result.length > 0) {
return { success: true, data: result };
}
return { success: false };
};
export const openDoor = async (doorKey) => {
const [result] = await pool.query(
"SELECT safe_nr, id FROM items WHERE door_key = ?;",
[doorKey]
);
if (result.length > 0) {
const [changeItemSate] = await pool.query(
"UPDATE items SET in_safe = NOT in_safe WHERE id = ?",
[result[0].id]
);
if (changeItemSate.affectedRows > 0) {
return { success: true, data: result[0] };
} else {
return { success: false };
}
}
return { success: false };
};

View File

@@ -0,0 +1,95 @@
import express from "express";
import { authenticate } from "../../services/authentication.js";
const router = express.Router();
import dotenv from "dotenv";
dotenv.config();
import {
getItemsFromDatabaseV2,
changeInSafeStateV2,
setTakeDateV2,
setReturnDateV2,
getLoanByCodeV2,
openDoor,
} from "./api.database.js";
// Route for API to get all items from the database
router.get("/items/:key", authenticate, async (req, res) => {
const result = await getItemsFromDatabaseV2();
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to fetch items" });
}
});
// Route for API to control the safe state of an item
router.post("/change-state/:key/:itemId", authenticate, async (req, res) => {
const itemId = req.params.itemId;
const result = await changeInSafeStateV2(itemId);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to update item state" });
}
});
// Route for API to get a loan by its code
router.get(
"/get-loan-by-code/:key/:loan_code",
authenticate,
async (req, res) => {
const loan_code = req.params.loan_code;
const result = await getLoanByCodeV2(loan_code);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
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,
async (req, res) => {
const loanCode = req.params.loan_code;
const result = await setReturnDateV2(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set return date" });
}
}
);
// Route for API to set the take away date by the loan code
router.post(
"/set-take-date/:key/:loan_code",
authenticate,
async (req, res) => {
const loanCode = req.params.loan_code;
const result = await setTakeDateV2(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set take date" });
}
}
);
// Route for API to open a door
router.get("/open-door/:key/:doorKey", authenticate, async (req, res) => {
const doorKey = req.params.doorKey;
const result = await openDoor(doorKey);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to open door" });
}
});
export default router;

View File

@@ -0,0 +1,262 @@
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 createLoanInDatabase = async (
username,
startDate,
endDate,
note,
itemIds
) => {
if (!username)
return { success: false, code: "BAD_REQUEST", message: "Missing username" };
if (!Array.isArray(itemIds) || itemIds.length === 0)
return {
success: false,
code: "BAD_REQUEST",
message: "No items provided",
};
if (!startDate || !endDate)
return { success: false, code: "BAD_REQUEST", message: "Missing dates" };
const start = new Date(startDate);
const end = new Date(endDate);
if (
!(start instanceof Date) ||
isNaN(start.getTime()) ||
!(end instanceof Date) ||
isNaN(end.getTime()) ||
start >= end
) {
return {
success: false,
code: "BAD_REQUEST",
message: "Invalid date range",
};
}
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
// Ensure all items exist and collect names + lockers
const [itemsRows] = await conn.query(
"SELECT id, item_name, safe_nr FROM items WHERE id IN (?)",
[itemIds]
);
if (!itemsRows || itemsRows.length !== itemIds.length) {
await conn.rollback();
return {
success: false,
code: "BAD_REQUEST",
message: "One or more items not found",
};
}
const itemNames = itemIds
.map(
(id) => itemsRows.find((r) => Number(r.id) === Number(id))?.item_name
)
.filter(Boolean);
// Build lockers array (unique, only 2-digit numbers from safe_nr)
const lockers = [
...new Set(
itemsRows
.map((r) => r.safe_nr)
.filter(
(sn) =>
sn !== null &&
sn !== undefined &&
Number.isInteger(Number(sn)) &&
Number(sn) >= 0 &&
Number(sn) <= 99
)
.map((sn) => Number(sn))
),
];
// Check availability (no overlap with existing loans)
const [confRows] = await conn.query(
`
SELECT COUNT(*) AS conflicts
FROM loans l
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
ON TRUE
WHERE jt.item_id IN (?)
AND l.deleted = 0
AND l.start_date < ?
AND COALESCE(l.returned_date, l.end_date) > ?
`,
[itemIds, end, start]
);
if (confRows?.[0]?.conflicts > 0) {
await conn.rollback();
return {
success: false,
code: "CONFLICT",
message: "One or more items are not available in the selected period",
};
}
// Generate unique loan_code (retry a few times)
let loanCode = null;
for (let i = 0; i < 6; i++) {
const candidate = Math.floor(100000 + Math.random() * 899999); // 6 digits
const [exists] = await conn.query(
"SELECT 1 FROM loans WHERE loan_code = ? LIMIT 1",
[candidate]
);
if (exists.length === 0) {
loanCode = candidate;
break;
}
}
if (!loanCode) {
await conn.rollback();
return {
success: false,
code: "SERVER_ERROR",
message: "Failed to generate unique loan code",
};
}
// Insert loan (now includes lockers)
const [insertRes] = await conn.query(
`
INSERT INTO loans (username, loan_code, start_date, end_date, lockers, loaned_items_id, loaned_items_name, note)
VALUES (?, ?, ?, ?, CAST(? AS JSON), CAST(? AS JSON), CAST(? AS JSON), ?)
`,
[
username,
loanCode,
new Date(start).toISOString().slice(0, 19).replace("T", " "),
new Date(end).toISOString().slice(0, 19).replace("T", " "),
JSON.stringify(lockers),
JSON.stringify(itemIds.map((n) => Number(n))),
JSON.stringify(itemNames),
note,
]
);
await conn.commit();
return {
success: true,
data: {
id: insertRes.insertId,
loan_code: loanCode,
username,
start_date: start,
end_date: end,
items: itemIds,
item_names: itemNames,
lockers,
},
};
} catch (err) {
await conn.rollback();
console.error("createLoanInDatabase error:", err);
return {
success: false,
code: "SERVER_ERROR",
message: "Failed to create loan",
};
} finally {
conn.release();
}
};
export const getLoanInfoWithID = async (loanId) => {
const [rows] = await pool.query("SELECT * FROM loans WHERE id = ?;", [
loanId,
]);
if (rows.length > 0) {
return { success: true, data: rows[0] };
}
return { success: false };
};
export const getLoansFromDatabase = async (username) => {
const [result] = await pool.query(
"SELECT * FROM loans WHERE username = ? AND deleted = 0;",
[username]
);
if (result.length > 0) {
return { success: true, status: true, data: result };
} else if (result.length === 0) {
return { success: true, status: true, data: [] };
}
return { success: false };
};
export const getBorrowableItemsFromDatabase = async (
startDate,
endDate,
role = 0
) => {
// Overlap if: loan.start < end AND effective_end > start
// effective_end is returned_date if set, otherwise end_date
const hasRoleFilter = Number(role) > 0;
const sql = `
SELECT i.*
FROM items i
WHERE ${hasRoleFilter ? "i.can_borrow_role >= ? AND " : ""}NOT EXISTS (
SELECT 1
FROM loans l
JOIN JSON_TABLE(l.loaned_items_id, '$[*]' COLUMNS (item_id INT PATH '$')) jt
WHERE jt.item_id = i.id
AND l.deleted = 0
AND l.start_date < ?
AND COALESCE(l.returned_date, l.end_date) > ?
);
`;
const params = hasRoleFilter
? [role, endDate, startDate]
: [endDate, startDate];
const [rows] = await pool.query(sql, params);
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const SETdeleteLoanFromDatabase = async (loanId) => {
const [result] = await pool.query(
"UPDATE loans SET deleted = 1 WHERE id = ?;",
[loanId]
);
if (result.affectedRows > 0) {
return { success: true };
} else {
return { success: false };
}
};
export const getALLLoans = async () => {
const [result] = await pool.query("SELECT * FROM loans WHERE deleted = 0;");
if (result.length > 0) {
return { success: true, data: result };
}
return { success: false };
};
export const getItems = async () => {
const [result] = await pool.query("SELECT * FROM items;");
if (result.length > 0) {
return { success: true, data: result };
}
return { success: false };
};

View File

@@ -0,0 +1,55 @@
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 loginFunc = async (username, password) => {
const [result] = await pool.query(
"SELECT * FROM users WHERE username = ? AND password = ?",
[username, password]
);
if (result.length > 0) return { success: true, data: result[0] };
return { success: false };
};
export const getItems = async () => {
const [rows] = await pool.query("SELECT * FROM items;");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
export const getALLLoans = async () => {
const [rows] = await pool.query("SELECT * FROM loans;");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
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]
);
if (user.length === 0) return { success: false };
// update password
const [result] = await pool.query(
"UPDATE users SET password = ? WHERE username = ?",
[newPassword, username]
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};

View File

@@ -0,0 +1,150 @@
import express from "express";
import { authenticate, generateToken } from "../../services/authentication.js";
const router = express.Router();
import dotenv from "dotenv";
dotenv.config();
// database funcs import
import {
createLoanInDatabase,
getLoanInfoWithID,
getLoansFromDatabase,
getBorrowableItemsFromDatabase,
getALLLoans,
getItems,
SETdeleteLoanFromDatabase,
} 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 || {};
if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ message: "Items array is required" });
}
// If dates are not provided, default to now .. +7 days
const start =
startDate ?? new Date().toISOString().slice(0, 19).replace("T", " ");
const end =
endDate ??
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 19)
.replace("T", " ");
// Coerce item IDs to numbers and filter invalids
const itemIds = items
.map((v) => Number(v))
.filter((n) => Number.isFinite(n));
if (itemIds.length === 0) {
return res.status(400).json({ message: "No valid item IDs provided" });
}
const result = await createLoanInDatabase(
req.user.username,
start,
end,
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
);
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" });
}
});
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.get("/all-items", authenticate, async (req, res) => {
const result = await getItems();
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch items" });
}
});
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.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" });
}
});
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" });
}
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;

View File

@@ -0,0 +1,181 @@
import nodemailer from "nodemailer";
import dotenv from "dotenv";
dotenv.config();
const formatDateTime = (value) => {
if (value == null) return "N/A";
const toOut = (d) => {
if (!(d instanceof Date) || isNaN(d.getTime())) return "N/A";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${dd}.${mm}.${yyyy} ${hh}:${mi} Uhr`;
};
if (value instanceof Date) return toOut(value);
if (typeof value === "number") return toOut(new Date(value));
const s = String(value).trim();
// Direct pattern: "YYYY-MM-DD[ T]HH:mm[:ss]"
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::\d{2})?/);
if (m) {
const [, y, M, d, h, min] = m;
return `${d}.${M}.${y} ${h}:${min} Uhr`;
}
// ISO or other parseable formats
const dObj = new Date(s);
if (!isNaN(dObj.getTime())) return toOut(dObj);
return "N/A";
};
function buildLoanEmail({ user, items, startDate, endDate, createdDate }) {
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
const itemsList =
Array.isArray(items) && items.length
? `<ul style="margin:4px 0 0 18px; padding:0;">${items
.map(
(i) =>
`<li style="margin:2px 0; color:#111827; line-height:1.3;">${i}</li>`
)
.join("")}</ul>`
: "<span style='color:#111827;'>N/A</span>";
return `<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<meta name="x-apple-disable-message-reformatting">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
:root { color-scheme: light; supported-color-schemes: light; }
body { margin:0; padding:0; }
/* Mobile stacking */
@media (max-width:480px) {
.outer { width:100% !important; }
.pad-sm { padding:16px !important; }
.w-label { width:120px !important; }
}
/* Dark-mode override safety */
@media (prefers-color-scheme: dark) {
body, table, td, p, a, h1, h2, h3 { background:#ffffff !important; color:#111827 !important; }
.brand-header { background:${brand} !important; color:#ffffff !important; }
a { color:${brand} !important; }
}
</style>
</head>
<body bgcolor="#ffffff" style="background:#ffffff; font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif; color:#111827; -webkit-text-size-adjust:100%;">
<!-- Preheader (hidden) -->
<div style="display:none; max-height:0; overflow:hidden; opacity:0; mso-hide:all;">
Neue Ausleihe erstellt Übersicht der Buchung.
</div>
<div role="article" aria-roledescription="email" lang="de" style="padding:24px; background:#f2f4f7;">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" class="outer" style="max-width:600px; margin:0 auto; background:#ffffff; border:1px solid #e5e7eb; border-radius:14px; overflow:hidden;">
<tr>
<td class="brand-header" style="padding:22px 26px; background:${brand}; color:#ffffff;">
<h1 style="margin:0; font-size:18px; line-height:1.35; font-weight:600;">Neue Ausleihe erstellt</h1>
</td>
</tr>
<tr>
<td class="pad-sm" style="padding:24px 26px; color:#111827;">
<p style="margin:0 0 14px 0; line-height:1.4;">Es wurde eine neue Ausleihe angelegt. Hier sind die Details:</p>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse; font-size:14px; line-height:1.3; background:#fcfcfd; border:1px solid #e5e7eb; border-radius:10px; overflow:hidden;">
<tbody>
<tr>
<td class="w-label" style="padding:10px 14px; color:#6b7280; width:170px; border-bottom:1px solid #ececec;">Benutzer</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${
user || "N/A"
}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280; vertical-align:top; border-bottom:1px solid #ececec;">Ausgeliehene Gegenstände</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${itemsList}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Startdatum</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
startDate
)}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280; border-bottom:1px solid #ececec;">Enddatum</td>
<td style="padding:10px 14px; font-weight:600; border-bottom:1px solid #ececec; color:#111827;">${formatDateTime(
endDate
)}</td>
</tr>
<tr>
<td style="padding:10px 14px; color:#6b7280;">Erstellt am</td>
<td style="padding:10px 14px; font-weight:600; color:#111827;">${formatDateTime(
createdDate
)}</td>
</tr>
</tbody>
</table>
<p style="margin:22px 0 0 0; font-size:14px;">
<a href="https://admin.insta.the1s.de/api" style="display:inline-block; background:${brand}; color:#ffffff; text-decoration:none; padding:10px 16px; border-radius:6px; font-weight:600; font-size:14px;" target="_blank" rel="noopener noreferrer">
Übersicht öffnen
</a>
</p>
<p style="margin:18px 0 0 0; font-size:12px; color:#6b7280; line-height:1.4;">
Diese E-Mail wurde automatisch vom Ausleihsystem gesendet. Bitte nicht antworten.
</p>
</td>
</tr>
</table>
</div>
</body>
</html>`;
}
function buildLoanEmailText({ user, items, startDate, endDate, createdDate }) {
const itemsText =
Array.isArray(items) && items.length ? items.join(", ") : "N/A";
return [
"Neue Ausleihe erstellt",
"",
`Benutzer: ${user || "N/A"}`,
`Gegenstände: ${itemsText}`,
`Start: ${formatDateTime(startDate)}`,
`Ende: ${formatDateTime(endDate)}`,
`Erstellt am: ${formatDateTime(createdDate)}`,
].join("\n");
}
export function sendMailLoan(user, items, startDate, endDate, createdDate) {
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST,
port: process.env.MAIL_PORT,
secure: true,
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASSWORD,
},
});
(async () => {
const info = await transporter.sendMail({
from: '"Ausleihsystem" <noreply@mcs-medien.de>',
to: process.env.MAIL_SENDEES,
subject: "Eine neue Ausleihe wurde erstellt!",
text: buildLoanEmailText({
user,
items,
startDate,
endDate,
createdDate,
}),
html: buildLoanEmail({ user, items, startDate, endDate, createdDate }),
});
// debugging logs
// console.log("Message sent:", info.messageId);
})();
// console.log("sendMailLoan called");
}

View File

@@ -0,0 +1,38 @@
import express from "express";
import { authenticate, generateToken } from "../../services/authentication.js";
const router = express.Router();
import dotenv from "dotenv";
dotenv.config();
// database funcs import
import { loginFunc, changePassword } from "./database/userMgmt.database.js";
router.post("/login", async (req, res) => {
const result = await loginFunc(req.body.username, req.body.password);
if (result.success) {
const token = await generateToken({
username: result.data.username,
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", 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" });
}
});
export default router;

Binary file not shown.

View File

@@ -1,91 +1,120 @@
-- MUST BE UPDATED BEFORE USE
USE borrow_system_new; USE borrow_system_new;
-- Optional: keep insert order predictable -- Reset tables (no FKs defined, so order is safe)
SET time_zone = '+00:00'; SET FOREIGN_KEY_CHECKS = 0;
TRUNCATE TABLE loans;
TRUNCATE TABLE apiKeys;
TRUNCATE TABLE items;
TRUNCATE TABLE users;
SET FOREIGN_KEY_CHECKS = 1;
-- Users -- Users (roles 16, plain-text passwords; is_admin is BOOL)
INSERT INTO users (username, password, first_name, last_name, role, is_admin) INSERT INTO users (username, password, email, first_name, last_name, role, is_admin) VALUES
VALUES ('admin', 'adminpass', 'admin@example.com', 'System', 'Admin', 6, TRUE),
('alice', 'password123', 'Alice', 'Andersen', 1, false), ('alice', 'alice123', 'alice@example.com', 'Alice', 'Andersen',1, FALSE),
('bob', 'password123', 'Bob', 'Berg', 2, false), ('bob', 'bob12345', 'bob@example.com', 'Bob', 'Berg', 2, FALSE),
('carol', 'password123', 'Carol', 'Christie', 2, false), ('carol', 'carol123', 'carol@example.com', 'Carol', 'Christensen', 3, FALSE),
('dave', 'password123', 'Dave', 'Dawson', 1, false), ('dave', 'dave123', 'dave@example.com', 'Dave', 'Dahl', 4, FALSE),
('eve', 'password123', 'Eve', 'Evans', 1, false), ('erin', 'erin123', 'erin@example.com', 'Erin', 'Enevoldsen', 5, FALSE),
('admin', 'password123', 'Admin', 'User', 3, true); ('frank', 'frank123', 'frank@example.com', 'Frank', 'Fisher', 2, FALSE),
('grace', 'grace123', 'grace@example.com', 'Grace', 'Gundersen',1, FALSE),
('heidi', 'heidi123', 'heidi@example.com', 'Heidi', 'Hansen', 4, FALSE),
('tech', 'techpass', 'tech@example.com', 'Tech', 'User', 5, TRUE);
-- Items -- Items (safe_nr is two digits or NULL; matches CHECK and UNIQUE constraint)
INSERT INTO items (item_name, can_borrow_role, in_safe, last_borrowed_person, currently_borrowing) INSERT INTO items (item_name, can_borrow_role, in_safe, safe_nr, last_borrowed_person, currently_borrowing) VALUES
VALUES ('Laptop A', 2, FALSE, NULL, 'grace', 'bob'),
('Canon EOS 90D Camera', 1, false, 'bob', 'alice'), ('Laptop B', 2, TRUE, '01', NULL, NULL),
('Rode NT1 Microphone', 1, true, 'dave', NULL), ('Camera Canon', 3, TRUE, '02', 'erin', NULL),
('MacBook Pro 13', 2, false, 'bob', 'carol'), ('Microphone Rode', 1, TRUE, '03', 'grace', NULL),
('Tripod Manfrotto', 1, false, 'carol', 'alice'), ('Tripod Manfrotto', 1, TRUE, '04', 'frank', NULL),
('LED Panel Aputure', 1, true, NULL, NULL), ('Oscilloscope Tek', 4, TRUE, '05', NULL, NULL),
('Zoom H6 Recorder', 1, true, 'dave', NULL), ('VR Headset', 3, FALSE, NULL, 'heidi', 'carol'),
('Wacom Intuos Tablet', 1, true, NULL, NULL), ('Keycard Programmer', 6, TRUE, '06', 'admin', NULL);
('DJI Ronin-S Gimbal', 2, true, NULL, NULL),
('Sony A7 III Body', 2, false, 'carol', 'eve'),
('Sigma 24-70mm Lens', 2, false, 'carol', 'eve');
-- Capture item IDs for JSON arrays -- Loans (JSON strings, 6-digit numeric loan_code per CHECK)
SET @id_canon = (SELECT id FROM items WHERE item_name='Canon EOS 90D Camera'); -- Assumes the items above have ids 1..8 in insert order
SET @id_rode = (SELECT id FROM items WHERE item_name='Rode NT1 Microphone');
SET @id_mac13 = (SELECT id FROM items WHERE item_name='MacBook Pro 13');
SET @id_tripod = (SELECT id FROM items WHERE item_name='Tripod Manfrotto');
SET @id_led = (SELECT id FROM items WHERE item_name='LED Panel Aputure');
SET @id_zoom = (SELECT id FROM items WHERE item_name='Zoom H6 Recorder');
SET @id_tablet = (SELECT id FROM items WHERE item_name='Wacom Intuos Tablet');
SET @id_ronin = (SELECT id FROM items WHERE item_name='DJI Ronin-S Gimbal');
SET @id_sony = (SELECT id FROM items WHERE item_name='Sony A7 III Body');
SET @id_sigma = (SELECT id FROM items WHERE item_name='Sigma 24-70mm Lens');
-- Loans
INSERT INTO loans ( INSERT INTO loans (
username, loan_code, start_date, end_date, take_date, returned_date, loaned_items_id, loaned_items_name, deleted username,
lockers,
loan_code,
start_date,
end_date,
take_date,
returned_date,
loaned_items_id,
loaned_items_name,
deleted,
note
) VALUES ) VALUES
-- Ongoing loan: Alice has Canon + Tripod -- Active loan: bob has Laptop A (item id 1, locker "01")
('alice', 100001, '2025-10-01 09:00:00', '2025-10-08 17:00:00', '2025-10-01 09:15:00', NULL, ('bob',
JSON_ARRAY(@id_canon, @id_tripod), '["01"]',
JSON_ARRAY('Canon EOS 90D Camera','Tripod Manfrotto'), '123456',
false '2025-11-15 09:00:00',
), '2025-11-22 17:00:00',
-- Ongoing loan: Carol has MacBook Pro 13 '2025-11-15 09:15:00',
('carol', 100002, '2025-10-03 10:00:00', '2025-10-10 16:00:00', '2025-10-03 10:05:00', NULL, NULL,
JSON_ARRAY(@id_mac13), '[1]',
JSON_ARRAY('MacBook Pro 13'), '["Laptop A"]',
false FALSE,
), 'Active loan - Laptop A'
-- Returned loan: Dave had Zoom + Rode ),
('dave', 100003, '2025-09-10 08:30:00', '2025-09-12 16:00:00', '2025-09-10 08:45:00', '2025-09-12 15:40:00', -- Returned loan: frank had Tripod Manfrotto (item id 5, locker "04")
JSON_ARRAY(@id_zoom, @id_rode), ('frank',
JSON_ARRAY('Zoom H6 Recorder','Rode NT1 Microphone'), '["04"]',
false '234567',
), '2025-10-01 10:00:00',
-- Cancelled/deleted booking (never taken): Bob reserved Tablet '2025-10-07 16:00:00',
('bob', 100004, '2025-10-05 09:00:00', '2025-10-06 09:00:00', NULL, NULL, '2025-10-01 10:05:00',
JSON_ARRAY(@id_tablet), '2025-10-05 15:30:00',
JSON_ARRAY('Wacom Intuos Tablet'), '[5]',
true '["Tripod Manfrotto"]',
), FALSE,
-- Ongoing loan, likely overdue: Eve has Sony + Sigma 'Completed loan'
('eve', 100005, '2025-10-15 11:00:00', '2025-10-20 12:00:00', '2025-10-15 11:10:00', NULL, ),
JSON_ARRAY(@id_sony, @id_sigma), -- Future reservation: dave will take Oscilloscope Tek (item id 6, locker "05")
JSON_ARRAY('Sony A7 III Body','Sigma 24-70mm Lens'), ('dave',
false '["05"]',
), '345678',
-- Completed single-day loan: Bob used LED panel '2025-12-10 09:00:00',
('bob', 100006, '2025-09-20 13:00:00', '2025-09-20 18:00:00', '2025-09-20 13:05:00', '2025-09-20 17:30:00', '2025-12-12 17:00:00',
JSON_ARRAY(@id_led), NULL,
JSON_ARRAY('LED Panel Aputure'), NULL,
false '[6]',
); '["Oscilloscope Tek"]',
FALSE,
'Reserved'
),
-- Active loan: carol has VR Headset (item id 7, locker "02")
('carol',
'["02"]',
'456789',
'2025-11-10 13:00:00',
'2025-11-20 12:00:00',
'2025-11-10 13:10:00',
NULL,
'[7]',
'["VR Headset"]',
FALSE,
'Active loan - VR Headset'
),
-- Soft-deleted historic loan: grace had Microphone + Tripod (item ids 4,5; lockers "03","04")
('grace',
'["03","04"]',
'567890',
'2025-09-01 09:00:00',
'2025-09-03 17:00:00',
'2025-09-01 09:10:00',
'2025-09-03 16:45:00',
'[4,5]',
'["Microphone Rode","Tripod Manfrotto"]',
TRUE,
'Canceled/soft-deleted record'
);
-- API keys -- API keys (8-digit numeric keys per CHECK)
INSERT INTO apiKeys (api_key, username) INSERT INTO apiKeys (api_key, entry_name, last_used_at) VALUES
VALUES ('12345678', 'CI token', '2025-11-15 08:00:00'),
(71002123, 'alice'), ('87654321', 'Local dev', NULL),
(71002124, 'bob'), ('00000001', 'Monitoring', '2025-11-10 12:30:00');
(71002125, 'carol'),
(99999999, 'admin');

View File

@@ -4,6 +4,7 @@ CREATE TABLE users (
id int NOT NULL AUTO_INCREMENT, id int NOT NULL AUTO_INCREMENT,
username varchar(100) NOT NULL UNIQUE, username varchar(100) NOT NULL UNIQUE,
password varchar(255) NOT NULL, password varchar(255) NOT NULL,
email varchar(255) NOT NULL,
first_name varchar(255) NOT NULL, first_name varchar(255) NOT NULL,
last_name varchar(255) NOT NULL, last_name varchar(255) NOT NULL,
role int NOT NULL, role int NOT NULL,
@@ -16,7 +17,8 @@ CREATE TABLE users (
CREATE TABLE loans ( CREATE TABLE loans (
id int NOT NULL AUTO_INCREMENT, id int NOT NULL AUTO_INCREMENT,
username varchar(100) NOT NULL, username varchar(100) NOT NULL,
loan_code int NOT NULL UNIQUE, lockers json NOT NULL DEFAULT ('[]'),
loan_code Char(6) NOT NULL UNIQUE,
start_date timestamp NOT NULL, start_date timestamp NOT NULL,
end_date timestamp NOT NULL, end_date timestamp NOT NULL,
take_date timestamp NULL DEFAULT NULL, take_date timestamp NULL DEFAULT NULL,
@@ -27,10 +29,7 @@ CREATE TABLE loans (
deleted bool NOT NULL DEFAULT false, deleted bool NOT NULL DEFAULT false,
note varchar(500) DEFAULT NULL, note varchar(500) DEFAULT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
CONSTRAINT fk_loans_username CHECK (loan_code REGEXP '^[0-9]{6}$')
FOREIGN KEY (username) REFERENCES users(username)
ON UPDATE CASCADE
ON DELETE RESTRICT
) ENGINE=InnoDB; ) ENGINE=InnoDB;
CREATE TABLE items ( CREATE TABLE items (
@@ -38,21 +37,21 @@ CREATE TABLE items (
item_name varchar(255) NOT NULL UNIQUE, item_name varchar(255) NOT NULL UNIQUE,
can_borrow_role INT NOT NULL, can_borrow_role INT NOT NULL,
in_safe bool NOT NULL DEFAULT true, in_safe bool NOT NULL DEFAULT true,
safe_nr INT DEFAULT NULL UNIQUE,
door_key INT DEFAULT NULL UNIQUE,
entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP,
entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_borrowed_person varchar(255) DEFAULT NULL, last_borrowed_person varchar(255) DEFAULT NULL,
currently_borrowing varchar(255) DEFAULT NULL, currently_borrowing varchar(255) DEFAULT NULL,
PRIMARY KEY (id) PRIMARY KEY (id)
); ) ENGINE=InnoDB;
CREATE TABLE apiKeys ( CREATE TABLE apiKeys (
id int NOT NULL AUTO_INCREMENT, id INT NOT NULL AUTO_INCREMENT,
api_key int NOT NULL UNIQUE, api_key CHAR(8) NOT NULL UNIQUE,
username VARCHAR(100) NOT NULL, entry_name VARCHAR(100) NOT NULL,
entry_created_at timestamp NULL DEFAULT CURRENT_TIMESTAMP, last_used_at TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
entry_created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
CONSTRAINT fk_apikeys_username CHECK (api_key REGEXP '^[0-9]{8}$')
FOREIGN KEY (username) REFERENCES users(username)
ON UPDATE CASCADE
ON DELETE RESTRICT
) ENGINE=InnoDB; ) ENGINE=InnoDB;

View File

@@ -1,22 +1,62 @@
import express from "express"; import express from "express";
import cors from "cors"; import cors from "cors";
import env from "dotenv"; import env from "dotenv";
import info from "./info.json" assert { type: "json" };
import { authenticate } from "./services/authentication.js";
// frontend routes
import loansMgmtRouter from "./routes/app/loanMgmt.route.js";
import userMgmtRouterAPP from "./routes/app/userMgmt.route.js";
// admin routes
import userDataMgmtRouter from "./routes/admin/userDataMgmt.route.js";
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";
// API routes
import apiRouter from "./routes/api/api.route.js";
env.config(); env.config();
const app = express(); const app = express();
const port = 8002; const port = 8004;
app.use(cors()); app.use(cors());
// Increase body size limits to support large CSV JSON payloads // Body-Parser VOR den Routen registrieren
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
app.set("view engine", "ejs");
app.use(express.json({ limit: "10mb" })); app.use(express.json({ limit: "10mb" }));
app.use(express.urlencoded({ extended: true, limit: "10mb" }));
// frontend routes
app.use("/api/loans", loansMgmtRouter);
app.use("/api/users", userMgmtRouterAPP);
// admin routes
app.use("/api/admin/loan-data", loanDataMgmtRouter);
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);
// API routes
app.use("/api", apiRouter);
app.set("view engine", "ejs");
app.listen(port, () => { app.listen(port, () => {
console.log(`Server is running on port: ${port}`); console.log(`Server is running on port: ${port}`);
}); });
app.get("/verify", authenticate, async (req, res) => {
res.status(200).json({ message: "Token is valid", user: req.user });
});
app.get("/", (req, res) => {
res.send(info);
});
// error handling code // error handling code
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
// Log the error stack and send a generic error response
console.error(err.stack); console.error(err.stack);
res.status(500).send("Something broke!"); res.status(500).send("Something broke!");
}); });

View File

@@ -0,0 +1,90 @@
import { SignJWT, jwtVerify } from "jose";
import env from "dotenv";
import { verifyAPIKeyDB } from "./database.js";
env.config();
const secretKey = process.env.SECRET_KEY;
if (!secretKey) {
throw new Error("Missing SECRET_KEY environment variable");
}
const secret = new TextEncoder().encode(secretKey);
export async function generateToken(payload) {
return await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("2h")
.sign(secret);
}
export async function authenticateAdmin(req, res, next) {
const authHeader = req.headers["authorization"];
if (!authHeader) {
return res.status(401).json({ message: "Unauthorized" });
}
const [scheme, token] = authHeader.split(" ");
if (!/^Bearer$/i.test(scheme) || !token) {
return res.status(401).json({ message: "Unauthorized" });
}
try {
const payload = await verifyToken(token);
if (!payload?.admin) {
return res.status(403).json({ message: "Forbidden: admin only" });
}
req.user = payload;
return next();
} catch {
return res.status(403).json({ message: "Forbidden 403" });
}
}
export async function authenticate(req, res, next) {
const authHeader = req.headers["authorization"];
const apiKey = req.params.key;
if (authHeader) {
const parts = authHeader.split(" ");
const scheme = parts[0];
const token = parts[1];
if (!/^Bearer$/i.test(scheme) || !token) {
return res.status(401).json({ message: "Unauthorized" });
}
try {
const payload = await verifyToken(token);
req.user = payload;
return next();
} catch {
return res.status(403).json({ message: "Present token invalid" }); // present token invalid
}
} else if (apiKey) {
try {
await verifyAPIKey(apiKey);
return next();
} catch {
return res.status(403).json({ message: "API Key invalid" }); // fix: don't chain after sendStatus
}
} else {
return res.status(401).json({ message: "Unauthorized" }); // no credentials
}
}
async function verifyAPIKey(apiKey) {
const result = await verifyAPIKeyDB(apiKey);
if (result.valid) {
return;
} else {
throw new Error("Invalid API Key");
}
}
async function verifyToken(token) {
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"],
});
return payload;
}

View File

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

View File

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

View File

@@ -1,72 +1,47 @@
services: services:
# borrow_system-frontend: # usr-frontend_v2:
# container_name: borrow_system-frontend # container_name: borrow_system-usr-frontend
# build: ./FrontendV2 # build: ./FrontendV2
# ports: # ports:
# - "8001:8001" # - "8001:80"
# environment:
# - CHOKIDAR_USEPOLLING=true
# volumes:
# - ./FrontendV2:/app
# - /app/node_modules
# restart: unless-stopped # restart: unless-stopped
# admin-frontend: # admin-frontend:
# container_name: admin-frontend # container_name: borrow_system-admin-frontend
# build: ./admin # build: ./admin
# ports: # ports:
# - "8003:8003" # - "8003:80"
# environment:
# - CHOKIDAR_USEPOLLING=true
# volumes:
# - ./admin:/app
# - /app/node_modules
# restart: unless-stopped # restart: unless-stopped
borrow_system-backend: backend_v2:
container_name: borrow_system-backend container_name: borrow_system-backend_v2
build: ./backend build: ./backendV2
ports: ports:
- "8002:8002" - "8004:8004"
environment: environment:
DB_HOST: mysql NODE_ENV: production
DB_HOST: mysql_v2
DB_USER: root DB_USER: root
DB_PASSWORD: ${DB_PASSWORD} DB_PASSWORD: ${DB_PASSWORD_V2}
DB_NAME: borrow_system DB_NAME: borrow_system_new
depends_on: depends_on:
- mysql - mysql_v2
volumes:
- ./backend:/borrow_system-backend
restart: unless-stopped restart: unless-stopped
mysql: mysql_v2:
container_name: borrow_system-mysql container_name: borrow_system-mysql-v2
image: mysql:8.0 image: mysql:8.0
restart: unless-stopped restart: unless-stopped
environment: environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD} MYSQL_ROOT_PASSWORD: ${DB_PASSWORD_V2}
MYSQL_DATABASE: borrow_system
TZ: Europe/Berlin
volumes:
- mysql-data:/var/lib/mysql
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
ports:
- "3309:3306"
mysql-new:
container_name: borrow_system-mysql-new
image: mysql:8.0
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: borrow_system_new MYSQL_DATABASE: borrow_system_new
TZ: Europe/Berlin TZ: Europe/Berlin
volumes: volumes:
- mysql-data-new:/var/lib/mysql - mysql-v2-data:/var/lib/mysql
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro - ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
ports: ports:
- "3310:3306" - "3310:3306"
volumes: volumes:
mysql-data: mysql-data:
mysql-data-new: mysql-v2-data:

41
next-js/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
next-js/README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

BIN
next-js/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

26
next-js/app/globals.css Normal file
View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

34
next-js/app/layout.tsx Normal file
View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

65
next-js/app/page.tsx Normal file
View File

@@ -0,0 +1,65 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid px-5 transition-colors hover:border-transparent dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}

18
next-js/eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

8
next-js/next.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true,
};
export default nextConfig;

6557
next-js/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
next-js/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "next-js",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.0.5",
"react": "19.2.0",
"react-dom": "19.2.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"babel-plugin-react-compiler": "1.0.0",
"eslint": "^9",
"eslint-config-next": "16.0.5",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
next-js/public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
next-js/public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
next-js/public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

34
next-js/tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}