Compare commits

...

65 Commits

Author SHA1 Message Date
theis.gaedigk 3ff31ecbf6 Merge branch 'dev' into host 2026-05-09 11:39:26 +02:00
theis.gaedigk c48da71cd5 improved translation 2026-05-09 11:37:44 +02:00
theis.gaedigk 8e35e81e8f Fixed bug: #16 2026-05-09 11:35:13 +02:00
theis.gaedigk 95aae1c050 cleaned changelog 2026-05-01 13:41:30 +02:00
theis.gaedigk 2cc5545ea8 fixed version info 2026-05-01 13:15:28 +02:00
theis.gaedigk 3f910f937b edited port on nginx config 2026-05-01 12:55:29 +02:00
theis.gaedigk 3ed6121a0b fixed version infos 2026-05-01 12:47:21 +02:00
theis.gaedigk 5d9e965597 fixed docker compose 2026-05-01 12:39:32 +02:00
theis.gaedigk e296de27ef Merge branch 'dev' into host 2026-05-01 12:35:45 +02:00
theis.gaedigk f8e29dca10 improved loan tabel on admin panel 2026-04-26 22:21:00 +02:00
theis.gaedigk e52fc13da4 updated changelog 2026-04-26 22:09:19 +02:00
theis.gaedigk 4291552b6d edited mailer and changed orchestration
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 21:52:35 +02:00
theis.gaedigk 5191871681 added feature: That no loan can be deleted and edited changelog accordingly 2026-04-26 19:35:48 +02:00
theis.gaedigk c61a283127 edited changelog and edited version numbers 2026-04-26 16:55:03 +02:00
theis.gaedigk 4a3c948386 implemented deactivated services banner
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 16:49:32 +02:00
theis.gaedigk 6fb03530df improved error logging
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 16:24:11 +02:00
theis.gaedigk d2c36e71be added request limiter to backend 2026-04-26 16:10:34 +02:00
theis.gaedigk 40d784ab36 implemented service configuration to admin panel
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 15:59:57 +02:00
theis.gaedigk 60c85efd37 refactored backend
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 15:03:37 +02:00
theis.gaedigk 747932cf03 implemented service configuration to API service
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 14:57:59 +02:00
theis.gaedigk 0964109c4b added new feature: service config; Currently implemented in: loanMgmt and userMgmt (only Backend)
Co-authored-by: Copilot <copilot@github.com>
2026-04-26 14:40:53 +02:00
theis.gaedigk 5442f2f1f3 new feature: Error code 507 will return if you want to delete a loan that has not been returned 2026-04-24 18:44:07 +02:00
theis.gaedigk 56e073244f edited version numbers 2026-04-21 21:54:09 +02:00
theis.gaedigk d2dc74971f edited changelog 2026-04-21 21:53:16 +02:00
theis.gaedigk 25709ea0d9 implemented naas in to user frontend 2026-04-21 21:49:54 +02:00
theis.gaedigk f8ab2490fe added no-as-a-service project as a submodule 2026-04-21 20:48:57 +02:00
theis.gaedigk 3de877dd2b added icon to header and updated changelog 2026-04-19 22:04:49 +02:00
theis.gaedigk 5d0134017a added changelog and edited version numbers 2026-04-19 21:47:53 +02:00
theis.gaedigk 5c7a96912b Merge branch 'dev' into debian12 2026-04-18 15:00:51 +02:00
theis.gaedigk 195f270064 Merge branch 'dev' into debian12 2026-02-22 23:46:35 +01:00
theis.gaedigk 3d592c5c76 Merge branch 'dev' into debian12 2026-02-22 23:44:46 +01:00
theis.gaedigk 08104d32db Merge branch 'dev' into debian12 2026-02-20 16:30:34 +01:00
theis.gaedigk cc7c024892 Merge branch 'dev' into debian12 2026-02-07 17:48:44 +01:00
theis.gaedigk 3eb452aeab fixed version 2026-02-07 17:41:09 +01:00
theis.gaedigk f46a654184 Merge branch 'dev' into debian12 2026-02-07 17:40:47 +01:00
theis.gaedigk 863409aed9 Merge branch 'dev' into debian12 2026-02-04 13:47:04 +01:00
theis.gaedigk 052137a697 removed ports 2026-02-01 15:50:52 +01:00
theis.gaedigk 2f3583ccd0 Merge branch 'dev' into debian12 2026-01-28 18:26:08 +01:00
theis.gaedigk 9da72cc5bf Merge branch 'dev' into debian12 2026-01-28 13:06:19 +01:00
theis.gaedigk c633627b7c Merge branch 'dev' into debian12 2026-01-28 12:44:25 +01:00
theis.gaedigk 5259c41b13 Merge branch 'dev' into debian12 2026-01-27 21:29:08 +01:00
theis.gaedigk 3d9e3814fe edited docker 2026-01-27 10:33:32 +01:00
theis.gaedigk b44edb2b1d chnaged config 2026-01-16 17:17:15 +01:00
theis.gaedigk a72fabc0a0 Merge branch 'dev' into debian12 2026-01-16 17:11:30 +01:00
theis.gaedigk 1406f28f86 Merge branch 'dev' into debian12 2026-01-07 15:06:51 +01:00
theis.gaedigk 38d1091e9b Merge branch 'dev' into debian12 2025-11-30 21:23:22 +01:00
theis.gaedigk f82efecb8c edited docker config 2025-11-30 21:21:21 +01:00
theis.gaedigk 1f12bc8839 t 2025-11-30 21:17:36 +01:00
theis.gaedigk f19750f6f3 edited port config 2025-11-30 21:12:14 +01:00
theis.gaedigk 808b3fd5c4 Merge branch 'dev' into debian12 2025-11-30 21:07:32 +01:00
theis.gaedigk 0891598eb9 changed version info 2025-11-25 17:30:56 +01:00
theis.gaedigk 39ff02f2e7 Merge branch 'dev' into debian12 2025-11-25 17:11:27 +01:00
theis.gaedigk cc67fb4f85 changed version info 2025-11-24 15:35:03 +01:00
theis.gaedigk 75ff4aadc1 fixed color bug 2025-11-24 14:16:55 +01:00
theis.gaedigk 6f998d07c1 Merge branch 'dev' into debian12 2025-11-23 21:52:34 +01:00
theis.gaedigk f2bb326040 Merge branch 'dev' into debian12 2025-11-23 21:40:11 +01:00
theis.gaedigk 8c701db900 changed ports 2025-11-23 21:11:23 +01:00
theis.gaedigk d1664338a6 add networks configuration for frontend and backend services in docker-compose 2025-11-23 21:06:12 +01:00
theis.gaedigk 1a2624cd9e again 2025-11-23 20:34:19 +01:00
theis.gaedigk a138190cc6 fixed bugs 2025-11-23 20:32:14 +01:00
theis.gaedigk 993e0cd74b fixed bugs 2025-11-23 20:29:31 +01:00
theis.gaedigk dab004a7b6 changed docker config 2025-11-23 20:26:27 +01:00
theis.gaedigk d039336f39 Merge branch 'dev' into debian12 2025-11-23 20:20:41 +01:00
theis.gaedigk 4c781e9325 changed ports 2025-11-23 20:12:41 +01:00
theis.gaedigk 451e6b3646 published v2 2025-11-23 20:11:36 +01:00
45 changed files with 1747 additions and 803 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "no-as-a-service"]
path = no-as-a-service
url = https://github.com/hotheadhacker/no-as-a-service.git
+3 -3
View File
@@ -5224,9 +5224,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.11",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.11.tgz",
"integrity": "sha512-5dDj8+lmvA8XB78SmzGI8NlQoksv7IfutGWeVZxiixHbO+p4LDPT3wuG/D9sM/wrjZZ9I+Siy/e117vbFPxSZg==",
"funding": [
{
"type": "opencollective",
@@ -0,0 +1,67 @@
import { Alert, Stack, VStack, Spinner, Text, Heading } from "@chakra-ui/react";
import { useEffect, useState } from "react";
import { API_BASE } from "@/config/api.config";
import Cookies from "js-cookie";
import { useTranslation } from "react-i18next";
export const DeactivatedServices = () => {
const { t } = useTranslation();
const [deactivatedServices, setDeactivatedServices] = useState<
{ function_name: string }[]
>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const fetchDeactivatedServices = async () => {
setIsLoading(true);
try {
const response = await fetch(
`${API_BASE}/api/users/deactivated-services`,
{
headers: {
Authorization: `Bearer ${Cookies.get("token") || ""}`,
},
},
);
if (response.ok) {
const data = await response.json();
setDeactivatedServices(data);
} else {
console.error("Failed to fetch deactivated services");
}
} catch (error) {
console.error("Error fetching deactivated services:", error);
}
setIsLoading(false);
};
fetchDeactivatedServices();
}, []);
return (
<>
{deactivatedServices.length >= 1 && (
<Stack gap="2">
<Heading size={"xl"}>{t("deactivated-services")}</Heading>
{isLoading && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">{t("loading")}</Text>
</VStack>
)}
{deactivatedServices.length >= 1 &&
deactivatedServices.map((item) => (
<Alert.Root key={item.function_name} status="warning">
<Alert.Indicator />
<Alert.Title>
{item.function_name} {t("is-deactivated")}
</Alert.Title>
</Alert.Root>
))}
</Stack>
)}
</>
);
};
+9
View File
@@ -1,6 +1,7 @@
import {
Button,
Flex,
Image,
Heading,
Stack,
Text,
@@ -68,6 +69,7 @@ export const Header = () => {
className="mb-6"
position="relative"
pr={{ base: 10, md: 0 }} // Platz für den Mobile-Button rechts
marginBottom={1}
>
{/* Mobile: Drei-Punkte-Button, vertikal zentriert im Header */}
<Box
@@ -190,6 +192,13 @@ export const Header = () => {
<Stack gap={1}>
{/* Titelzeile ohne Mobile-Menu (wurde nach oben verlegt) */}
<Flex align="center" justify="space-between" gap={2}>
<Image
src="/icon_borrow-system-frontend_dark.png"
alt="borrow-system logo"
boxSize="10"
objectFit="contain"
flexShrink={0}
/>
<Heading
size="2xl"
className="tracking-tight text-slate-900 dark:text-slate-100"
+78 -5
View File
@@ -36,12 +36,43 @@ export const UserDialogue = (props: UserDialogueProps) => {
const [msgTitle, setMsgTitle] = useState("");
const [msgDescription, setMsgDescription] = useState("");
const [isMsgNAAS, setIsMsgNAAS] = useState(false);
const [msgStatusNAAS, setMsgStatusNAAS] = useState<"error" | "success">(
"error",
);
const [msgTitleNAAS, setMsgTitleNAAS] = useState("");
const [msgDescriptionNAAS, setMsgDescriptionNAAS] = useState("");
const [oldPassword, setOldPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
// Dialog control
const [isPwOpen, setPwOpen] = useState(false);
const [naasDialog, setNaasDialog] = useState(false);
const [naas, setNaas] = useState("");
const openNAAS = async () => {
try {
const response = await fetch(`${API_BASE}/no`, {
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
const data = await response.json();
setNaas(data.reason);
setNaasDialog(true);
} catch (error) {
setMsgStatusNAAS("error");
setMsgTitleNAAS(t("naas-error"));
setMsgDescriptionNAAS(t("naas-error-desc"));
setIsMsgNAAS(true);
console.log(msgStatusNAAS, msgTitleNAAS, msgDescriptionNAAS);
}
};
const changePassword = async () => {
if (newPassword !== confirmPassword) {
@@ -147,14 +178,31 @@ export const UserDialogue = (props: UserDialogueProps) => {
</Button>
</Stack>
</Card.Body>
<Card.Footer justifyContent="flex-end">
<Button variant="outline" onClick={() => props.setUserDialog(false)}>
{t("cancel")}
</Button>
<Card.Footer>
<Stack w="100%" gap={3}>
{isMsgNAAS && (
<MyAlert
status={msgStatusNAAS}
title={msgTitleNAAS}
description={msgDescriptionNAAS}
/>
)}
<HStack justify="flex-end" gap={2} wrap="wrap">
<Button
variant="outline"
onClick={() => props.setUserDialog(false)}
>
{t("cancel")}
</Button>
<Button variant="outline" onClick={() => openNAAS()}>
{t("try-naas")}
</Button>
</HStack>
</Stack>
</Card.Footer>
</Card.Root>
{/* Passwort-Dialog (kontrolliert) */}
{/* Passwort-Dialog */}
<Dialog.Root open={isPwOpen} onOpenChange={(e: any) => setPwOpen(e.open)}>
<Portal>
<Dialog.Backdrop />
@@ -215,6 +263,31 @@ export const UserDialogue = (props: UserDialogueProps) => {
</Dialog.Positioner>
</Portal>
</Dialog.Root>
<HStack wrap="wrap" gap="4">
<Dialog.Root
placement={"center"}
open={naasDialog}
motionPreset="slide-in-bottom"
>
<Portal>
<Dialog.Backdrop />
<Dialog.Positioner>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{t("naas-header")}</Dialog.Title>
</Dialog.Header>
<Dialog.Body>
<p>{naas}</p>
</Dialog.Body>
<Dialog.CloseTrigger asChild>
<CloseButton onClick={() => setNaasDialog(false)} size="sm" />
</Dialog.CloseTrigger>
</Dialog.Content>
</Dialog.Positioner>
</Portal>
</Dialog.Root>
</HStack>
</Flex>
);
};
+17 -9
View File
@@ -1,15 +1,23 @@
"use client"
"use client";
import { ChakraProvider, defaultSystem } from "@chakra-ui/react"
import {
ColorModeProvider,
type ColorModeProviderProps,
} from "./color-mode"
import { ChakraProvider, defaultSystem } from "@chakra-ui/react";
import * as React from "react";
import type { ReactNode } from "react";
import { ColorModeProvider as ThemeColorModeProvider } from "./color-mode";
export function Provider(props: ColorModeProviderProps) {
export interface ColorModeProviderProps {
children: React.ReactNode;
}
export function ColorModeProvider({ children }: ColorModeProviderProps) {
// Wrap children with the real color-mode provider
return <ThemeColorModeProvider>{children}</ThemeColorModeProvider>;
}
export function Provider({ children }: { children: ReactNode }) {
return (
<ChakraProvider value={defaultSystem}>
<ColorModeProvider {...props} />
<ColorModeProvider>{children}</ColorModeProvider>
</ChakraProvider>
)
);
}
+27
View File
@@ -5,6 +5,8 @@ import {
Alert,
Container,
Text,
VStack,
Spinner,
} from "@chakra-ui/react";
import { useTranslation } from "react-i18next";
import { useState } from "react";
@@ -21,9 +23,21 @@ interface Alert {
export const ContactPage = () => {
const { t } = useTranslation();
const [message, setMessage] = useState("");
const [isSending, setIsSending] = useState(false);
const [alert, setAlert] = useState<Alert | null>(null);
const sendMessage = async () => {
setIsSending(true);
if (message.trim() === "") {
setAlert({
type: "error",
headline: t("contactPage_messageErrorHeadline"),
text: t("contactPage_messageErrorText2"),
});
setIsSending(false);
return;
}
// Logic to send the message
const result = await fetch(`${API_BASE}/api/users/contact`, {
method: "POST",
@@ -42,6 +56,12 @@ export const ContactPage = () => {
text: t("contactPage_successText"),
});
setMessage("");
} else if (result.status === 503) {
setAlert({
type: "error",
headline: t("serviceDeactivatedHeadline"),
text: t("contactPage_serviceDeactivatedText"),
});
} else {
setAlert({
type: "error",
@@ -49,6 +69,7 @@ export const ContactPage = () => {
text: t("contactPage_errorText"),
});
}
setIsSending(false);
};
return (
@@ -78,6 +99,12 @@ export const ContactPage = () => {
</Alert.Content>
</Alert.Root>
)}
{isSending && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">{t("loading")}</Text>
</VStack>
)}
<Button onClick={sendMessage}>{t("contactPage_sendButton")}</Button>
</Container>
);
+13 -9
View File
@@ -19,6 +19,7 @@ import { createLoan } from "@/utils/Fetcher";
import { Header } from "@/components/Header";
import { useTranslation } from "react-i18next";
import { approvalAnimation } from "@/components/dotLottie";
import { DeactivatedServices } from "@/components/DeactivatedServices";
export interface User {
username: string;
@@ -71,6 +72,7 @@ export const HomePage = () => {
)}
<Container className="px-6 sm:px-8 pt-10">
<Header />
<DeactivatedServices />
{isMsg && (
<MyAlert
status={msgStatus}
@@ -131,12 +133,6 @@ export const HomePage = () => {
>
{t("get-borrowable-items")}
</Button>
{isLoadingA && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">{t("loading")}</Text>
</VStack>
)}
{borrowableItems.length > 0 && (
<Table.ScrollArea borderWidth="1px" rounded="md">
<Table.Root size="sm" stickyHeader>
@@ -189,9 +185,11 @@ export const HomePage = () => {
)}
{selectedItems.length >= 1 && (
<Button
onClick={() =>
onClick={() => {
setIsLoadingA(true);
createLoan(selectedItems, startDate, endDate, note).then(
(response) => {
setIsLoadingA(false);
if (response.status === "error") {
setMsgStatus("error");
setMsgTitle(response.title || t("error"));
@@ -207,12 +205,18 @@ export const HomePage = () => {
setMsgDescription(t("loan-success"));
setIsMsg(true);
},
)
}
);
}}
>
{t("create-loan")}
</Button>
)}
{isLoadingA && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">{t("loading")}</Text>
</VStack>
)}
</Stack>
</Container>
</>
+10
View File
@@ -79,6 +79,16 @@ const Landingpage: React.FC = () => {
Authorization: `Bearer ${Cookies.get("token")}`,
},
});
if (loanRes.status === 503) {
setError(
"error",
t("serviceDeactivatedHeadline"),
t("loan_page_serviceDeactivatedText"),
);
setIsLoading(false);
return;
}
const loanData = await loanRes.json();
if (Array.isArray(loanData)) {
setLoans(loanData);
+39 -6
View File
@@ -52,6 +52,13 @@ export const MyLoansPage = () => {
});
if (!res.ok) {
if (res.status === 503) {
setMsgStatus("error");
setMsgTitle(t("serviceDeactivatedHeadline"));
setMsgDescription(t("loan_page_serviceDeactivatedText"));
setIsMsg(true);
return;
}
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("error-fetching-loans"));
@@ -84,6 +91,14 @@ export const MyLoansPage = () => {
});
if (!res.ok) {
if (res.status === 507) {
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("error-deleting-loan-507"));
setIsMsg(true);
return;
}
setMsgStatus("error");
setMsgTitle(t("error"));
setMsgDescription(t("error-deleting-loan"));
@@ -106,10 +121,28 @@ export const MyLoansPage = () => {
const formatDate = (iso: string | null) => {
if (!iso) return "-";
const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/);
if (!m) return iso;
const [, y, M, d, h, min] = m;
return `${d}.${M}.${y} ${h}:${min}`;
const date = new Date(iso);
if (isNaN(date.getTime())) return iso;
return date.toLocaleString("de-DE", {
timeZone: "Europe/Berlin",
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const dateAndTime = (isISO: boolean) => {
const date = new Date();
if (isISO) {
return date.toISOString();
}
if (!isISO) {
return date;
}
};
const handleTakeAction = async (loanCode: string) => {
@@ -136,7 +169,7 @@ export const MyLoansPage = () => {
setLoans((prev) =>
prev.map((loan) =>
loan.loan_code === loanCode
? { ...loan, take_date: new Date().toISOString() }
? { ...loan, take_date: dateAndTime(true) }
: loan,
),
);
@@ -176,7 +209,7 @@ export const MyLoansPage = () => {
setLoans((prev) =>
prev.map((loan) =>
loan.loan_code === loanCode
? { ...loan, returned_date: new Date().toISOString() }
? { ...loan, returned_date: dateAndTime(true) }
: loan,
),
);
+20
View File
@@ -17,6 +17,16 @@ export const getBorrowableItems = async (
});
if (!response.ok) {
if (response.status === 503) {
return {
data: null,
status: "error",
title: "Service deactivated",
description:
"The loan service is currently deactivated. Please try again later.",
};
}
return {
data: null,
status: "error",
@@ -60,6 +70,16 @@ export const createLoan = async (
});
if (!response.ok) {
if (response.status === 503) {
return {
data: null,
status: "error",
title: "Service deactivated",
description:
"The loan service is currently deactivated. Please try again later.",
};
}
return {
data: null,
status: "error",
+15 -2
View File
@@ -60,7 +60,7 @@
"sure-delete-loan-2": "Für den Admin bleibt sie weiterhin sichtbar.",
"delete": "Löschen",
"change-language": "Sprache ändern",
"timezone-info": "Die angezeigten Daten und Uhrzeiten werden in deutscher Zeitzone dargestellt und müssen auch so eingegeben werden.",
"timezone-info": "Die angezeigten Daten und Uhrzeiten werden in deutscher Zeitzone dargestellt und müssen auch so eingegeben werden. Das gesamte System ist auf die deutsche Zeitzone eingestellt.",
"optional-note": "Optionale Notiz",
"note": "Notiz",
"user-info-desc": "Hier können Sie Ihre persönlichen Informationen einsehen und das Passwort ändern. Falls Sie weitere Änderungen benötigen, wenden Sie sich bitte an einen Administrator.",
@@ -88,5 +88,18 @@
"take-loan-success": "Ausleihe erfolgreich abgeholt",
"return-loan-success": "Ausleihe erfolgreich zurückgegeben",
"network-error": "Netzwerkfehler. Kontaktieren Sie den Administrator.",
"contactPage_messageDescription": "Bitte geben Sie hier Ihre Nachricht ein. Der Systemadministrator (Theis Gaedigk) wird sich so schnell wie möglich bei Ihnen melden."
"contactPage_messageDescription": "Bitte geben Sie hier Ihre Nachricht ein. Der Systemadministrator (Theis Gaedigk) wird sich so schnell wie möglich bei Ihnen melden.",
"naas": "No-as-a-service",
"try-naas": "Klick mich",
"naas-error": "Fehler mit no-as-a-service",
"naas-error-desc": "Ein Fehler ist beim Kommunizieren mit no-as-a-service aufgetreten.",
"naas-header": "Eine gute Möglichkeit, nein zu sagen...",
"error-deleting-loan-507": "Die Ausleihe kann nicht gelöscht werden, da sie noch nicht zurückgegeben wurde.",
"serviceDeactivatedHeadline": "Service deaktiviert",
"contactPage_serviceDeactivatedText": "Der Kontaktservice ist derzeit deaktiviert. Bitte versuchen Sie es später erneut.",
"loan_page_serviceDeactivatedText": "Der Ausleihservice ist derzeit deaktiviert. Bitte versuchen Sie es später erneut.",
"is-deactivated": "ist deaktiviert.",
"deactivated-services": "Deaktivierte Services",
"contactPage_messageErrorHeadline": "Fehler bei der Nachrichteneingabe",
"contactPage_messageErrorText2": "Bitte geben Sie eine Nachricht ein, bevor Sie sie senden."
}
+15 -2
View File
@@ -60,7 +60,7 @@
"sure-delete-loan-2": "It will remain visible to the admin.",
"delete": "Delete",
"change-language": "Change language",
"timezone-info": "The displayed dates and times are shown in Berlin timezone and must also be entered as such.",
"timezone-info": "The displayed dates and times are shown in Berlin timezone and must also be entered as such. The entire system is set to Berlin timezone.",
"optional-note": "Optional note",
"note": "Note",
"user-info-desc": "Here you can view your personal information and change your password. If you need to make further changes, please contact an administrator.",
@@ -88,5 +88,18 @@
"take-loan-success": "Loan taken successfully",
"return-loan-success": "Loan returned successfully",
"network-error": "Network error. Please contact the administrator.",
"contactPage_messageDescription": "Please enter your message here. The system administrator (Theis Gaedigk) will get back to you as soon as possible."
"contactPage_messageDescription": "Please enter your message here. The system administrator (Theis Gaedigk) will get back to you as soon as possible.",
"naas": "No-as-a-service",
"try-naas": "Click me",
"naas-error": "Error with no-as-a-service",
"naas-error-desc": "An error occurred while communicating with no-as-a-service.",
"naas-header": "A good way to say no...",
"error-deleting-loan-507": "The loan cannot be deleted because it has not been returned yet.",
"serviceDeactivatedHeadline": "Service deactivated",
"contactPage_serviceDeactivatedText": "The contact service is currently deactivated. Please try again later.",
"loan_page_serviceDeactivatedText": "The loan service is currently deactivated. Please try again later.",
"is-deactivated": "is deactivated.",
"deactivated-services": "Deactivated services",
"contactPage_messageErrorHeadline": "Error submitting message",
"contactPage_messageErrorText2": "Please enter a message before sending it."
}
+14 -7
View File
@@ -1,16 +1,23 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import svgr from "vite-plugin-svgr";
import tailwindcss from "@tailwindcss/vite";
import tsconfigPaths from "vite-tsconfig-paths";
import path from "node:path";
export default defineConfig({
plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
plugins: [tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
server: {
host: "0.0.0.0",
port: 8001,
watch: {
usePolling: true,
allowedHosts: ["insta.the1s.de"],
port: 8101,
watch: { usePolling: true },
hmr: {
host: "insta.the1s.de",
port: 8101,
protocol: "wss",
},
},
});
+1 -1
View File
@@ -14,7 +14,7 @@ server {
}
location /backend/ {
proxy_pass http://borrow_system-backend_v2:8004/;
proxy_pass http://borrow_system-backend_v2:8102/;
}
location ~* \.(?:js|mjs|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
+5 -2
View File
@@ -7,6 +7,7 @@ import UserTable from "../components/UserTable";
import ItemTable from "../components/ItemTable";
import LoanTable from "../components/LoanTable";
import APIKeyTable from "@/components/APIKeyTable";
import ServerConfig from "@/components/ServerConfig";
import { MoveLeft } from "lucide-react";
type DashboardProps = {
@@ -44,8 +45,9 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
viewSchliessfaecher={() => setActiveView("Schließfächer")}
viewUser={() => setActiveView("User")}
viewAPI={() => setActiveView("API")}
viewConfig={() => setActiveView("Server Konfiguration")}
/>
<Box flex="1" display="flex" flexDirection="column">
<Box flex="1" display="flex" flexDirection="column" minH={0}>
<Flex
as="header"
align="center"
@@ -66,7 +68,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
</Button>
</Flex>
</Flex>
<Box as="main" flex="1" p={6}>
<Box as="main" flex="1" p={6} minH={0} overflow="hidden">
{activeView === "" && (
<Flex
align="center"
@@ -88,6 +90,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
{activeView === "Ausleihen" && <LoanTable />}
{activeView === "Gegenstände" && <ItemTable />}
{activeView === "API" && <APIKeyTable />}
{activeView === "Server Konfiguration" && <ServerConfig />}
</Box>
</Box>
</Flex>
+11
View File
@@ -9,6 +9,7 @@ type SidebarProps = {
viewSchliessfaecher: () => void;
viewUser: () => void;
viewAPI: () => void;
viewConfig: () => void;
};
const Sidebar: React.FC<SidebarProps> = ({
@@ -16,6 +17,7 @@ const Sidebar: React.FC<SidebarProps> = ({
viewGegenstaende,
viewUser,
viewAPI,
viewConfig
}) => {
const [info, setInfo] = useState<any>(null);
@@ -83,6 +85,15 @@ const Sidebar: React.FC<SidebarProps> = ({
>
API Keys
</Link>
<Link
px={3}
py={2}
rounded="md"
_hover={{ bg: "gray.700", textDecoration: "none" }}
onClick={viewConfig}
>
Server Konfiguration
</Link>
</VStack>
<Box mt="auto" pt={8} fontSize="xs" color="gray.500">
+228 -184
View File
@@ -57,32 +57,32 @@ const ItemTable: React.FC = () => {
const handleItemNameChange = (id: number, value: string) => {
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, item_name: value } : it))
prev.map((it) => (it.id === id ? { ...it, item_name: value } : it)),
);
};
const handleCanBorrowRoleChange = (id: number, value: string) => {
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, can_borrow_role: value } : it))
prev.map((it) => (it.id === id ? { ...it, can_borrow_role: value } : it)),
);
};
const handleLockerNumberChange = (id: number, value: string) => {
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, safe_nr: value } : it))
prev.map((it) => (it.id === id ? { ...it, safe_nr: value } : it)),
);
};
const handleDoorKeyChange = (id: number, value: string) => {
setItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, door_key: value } : it))
prev.map((it) => (it.id === id ? { ...it, door_key: value } : it)),
);
};
const setError = (
status: "error" | "success",
message: string,
description: string
description: string,
) => {
setIsError(false);
setErrorStatus(status);
@@ -102,7 +102,7 @@ const ItemTable: React.FC = () => {
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
}
},
);
const data = await response.json();
return data;
@@ -193,185 +193,229 @@ const ItemTable: React.FC = () => {
{/* make table fill available width, like UserTable */}
{!isLoading && (
<Table.Root
size="sm"
striped
w="100%"
style={{ tableLayout: "auto" }} // Spalten nach Content
>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Gegenstand</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausleih Berechtigung</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Im Schließfach</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Schließfachnummer</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Schlüssel</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Eintrag erstellt am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Eintrag aktualisiert am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>LaP *</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Dav **</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Aktionen</strong>
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{items.map((item) => (
<Table.Row key={item.id}>
<Table.Cell>{item.id}</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleItemNameChange(item.id, e.target.value)
}
value={item.item_name}
/>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleCanBorrowRoleChange(item.id, e.target.value)
}
value={item.can_borrow_role}
/>
</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
changeSafeState(item.id).then(() => setReload(!reload))
}
size="xs"
rounded="full"
px={3}
py={1}
gap={2}
variant="ghost"
color={item.in_safe ? "green.600" : "red.600"}
borderWidth="1px"
borderColor={item.in_safe ? "green.300" : "red.300"}
_hover={{
bg: item.in_safe ? "green.50" : "red.50",
borderColor: item.in_safe ? "green.400" : "red.400",
transform: "translateY(-1px)",
shadow: "sm",
}}
_active={{ transform: "translateY(0)" }}
aria-label={
item.in_safe ? "Mark as not in safe" : "Mark as in safe"
}
>
<Icon
as={item.in_safe ? CheckCircle2 : XCircle}
boxSize={3.5}
mr={2}
/>
<Text as="span" fontSize="xs" fontWeight="semibold">
{item.in_safe ? "Yes" : "No"}
</Text>
</Button>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleLockerNumberChange(item.id, e.target.value)
}
value={item.safe_nr}
/>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleDoorKeyChange(item.id, e.target.value)
}
value={item.door_key}
/>
</Table.Cell>
<Table.Cell>{formatDateTime(item.entry_created_at)}</Table.Cell>
<Table.Cell>{formatDateTime(item.entry_updated_at)}</Table.Cell>
<Table.Cell>{item.last_borrowed_person}</Table.Cell>
<Table.Cell>{item.currently_borrowing}</Table.Cell>
<Table.Cell whiteSpace="nowrap">
<Button
onClick={() =>
handleEditItems(
item.id,
item.item_name,
item.safe_nr,
item.door_key,
item.can_borrow_role
).then((response) => {
if (response.success) {
setError(
"success",
"Gegenstand erfolgreich bearbeitet!",
"Gegenstand " +
'"' +
item.item_name +
'" mit ID ' +
item.id +
" bearbeitet."
);
}
})
}
colorPalette="teal"
size="sm"
>
<Save />
</Button>
<Button
onClick={() =>
deleteItem(item.id).then((response) => {
if (response.success) {
setItems(items.filter((i) => i.id !== item.id));
setError(
"success",
"Gegenstand gelöscht",
"Der Gegenstand wurde erfolgreich gelöscht."
);
}
})
}
colorPalette="red"
size="sm"
ml={2}
>
<Trash2 />
</Button>
</Table.Cell>
<Table.ScrollArea flex="1" minH={0} rounded="md" mt={4}>
<Table.Root
size="sm"
striped
stickyHeader
css={{
"& [data-sticky]": {
position: "sticky",
zIndex: 1,
bg: "bg",
_after: {
content: '""',
position: "absolute",
pointerEvents: "none",
top: "0",
bottom: "-1px",
width: "32px",
},
},
"& [data-sticky=end]": {
_after: {
insetInlineEnd: "0",
translate: "100% 0",
shadow: "inset 8px 0px 8px -8px rgba(0, 0, 0, 0.16)",
},
},
"& [data-sticky=start]": {
_after: {
insetInlineStart: "0",
translate: "-100% 0",
shadow: "inset -8px 0px 8px -8px rgba(0, 0, 0, 0.16)",
},
},
"& thead tr": {
shadow: "0 1px 0 0 {colors.border}",
"&:has(th[data-sticky])": {
zIndex: 2,
},
},
}}
>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Gegenstand</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausleih Berechtigung</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Im Schließfach</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Schließfachnummer</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Schlüssel</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Eintrag erstellt am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Eintrag aktualisiert am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>LaP *</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Dav **</strong>
</Table.ColumnHeader>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>Aktionen</strong>
</Table.ColumnHeader>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Table.Header>
<Table.Body>
{items.map((item) => (
<Table.Row key={item.id}>
<Table.Cell>{item.id}</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleItemNameChange(item.id, e.target.value)
}
value={item.item_name}
/>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleCanBorrowRoleChange(item.id, e.target.value)
}
value={item.can_borrow_role}
/>
</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
changeSafeState(item.id).then(() => setReload(!reload))
}
size="xs"
rounded="full"
px={3}
py={1}
gap={2}
variant="ghost"
color={item.in_safe ? "green.600" : "red.600"}
borderWidth="1px"
borderColor={item.in_safe ? "green.300" : "red.300"}
_hover={{
bg: item.in_safe ? "green.50" : "red.50",
borderColor: item.in_safe ? "green.400" : "red.400",
transform: "translateY(-1px)",
shadow: "sm",
}}
_active={{ transform: "translateY(0)" }}
aria-label={
item.in_safe ? "Mark as not in safe" : "Mark as in safe"
}
>
<Icon
as={item.in_safe ? CheckCircle2 : XCircle}
boxSize={3.5}
mr={2}
/>
<Text as="span" fontSize="xs" fontWeight="semibold">
{item.in_safe ? "Yes" : "No"}
</Text>
</Button>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleLockerNumberChange(item.id, e.target.value)
}
value={item.safe_nr}
/>
</Table.Cell>
<Table.Cell>
<Input
size="sm"
w="max-content"
onChange={(e) =>
handleDoorKeyChange(item.id, e.target.value)
}
value={item.door_key}
/>
</Table.Cell>
<Table.Cell>
{formatDateTime(item.entry_created_at)}
</Table.Cell>
<Table.Cell>
{formatDateTime(item.entry_updated_at)}
</Table.Cell>
<Table.Cell>{item.last_borrowed_person}</Table.Cell>
<Table.Cell>{item.currently_borrowing}</Table.Cell>
<Table.Cell whiteSpace="nowrap">
<Button
onClick={() =>
handleEditItems(
item.id,
item.item_name,
item.safe_nr,
item.door_key,
item.can_borrow_role,
).then((response) => {
if (response.success) {
setError(
"success",
"Gegenstand erfolgreich bearbeitet!",
"Gegenstand " +
'"' +
item.item_name +
'" mit ID ' +
item.id +
" bearbeitet.",
);
}
})
}
colorPalette="teal"
size="sm"
>
<Save />
</Button>
<Button
onClick={() =>
deleteItem(item.id).then((response) => {
if (response.success) {
setItems(items.filter((i) => i.id !== item.id));
setError(
"success",
"Gegenstand gelöscht",
"Der Gegenstand wurde erfolgreich gelöscht.",
);
}
})
}
colorPalette="red"
size="sm"
ml={2}
>
<Trash2 />
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Table.ScrollArea>
)}
<Text>* LaP = Letzte ausleihende Person</Text>
<Text>** Dav = Derzeit ausgeliehen von</Text>
+127 -81
View File
@@ -1,5 +1,6 @@
import React from "react";
import {
Box,
Table,
Spinner,
Text,
@@ -31,7 +32,7 @@ const LoanTable: React.FC = () => {
const setError = (
status: "error" | "success",
message: string,
description: string
description: string,
) => {
setIsError(false);
setErrorStatus(status);
@@ -65,7 +66,7 @@ const LoanTable: React.FC = () => {
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
}
},
);
const data = await response.json();
return data;
@@ -83,7 +84,7 @@ const LoanTable: React.FC = () => {
}, [reload]);
return (
<>
<Box h="full" display="flex" flexDirection="column" minH={0}>
{/* Action toolbar */}
<HStack
mb={4}
@@ -131,86 +132,131 @@ const LoanTable: React.FC = () => {
</VStack>
)}
{!isLoading && (
<Table.Root size="sm" striped>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Besitzer</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausleih code</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Startdatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Enddatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausleihdatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Rückgabedatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Erstellt am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausgeliehene Artikel</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Notiz</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Aktionen</strong>
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{items.map((item) => (
<Table.Row color={item.deleted ? "red" : "white"} key={item.id}>
<Table.Cell>{item.id}</Table.Cell>
<Table.Cell>{item.username}</Table.Cell>
<Table.Cell>
<Code>{item.loan_code}</Code>
</Table.Cell>
<Table.Cell>{formatDateTime(item.start_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.end_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.take_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.returned_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.created_at)}</Table.Cell>
<Table.Cell>{item.loaned_items_name.join(", ")}</Table.Cell>
<Table.Cell>{item.note}</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
deleteLoan(item.id).then((response) => {
if (response.success) {
setItems(items.filter((i) => i.id !== item.id));
setError(
"success",
"Loan deleted",
"The loan has been successfully deleted."
);
}
})
}
colorPalette="red"
size="sm"
ml={2}
>
<Trash2 />
</Button>
</Table.Cell>
<Table.ScrollArea flex="1" minH={0} rounded="md" mt={4}>
<Table.Root
size="sm"
striped
stickyHeader
css={{
"& [data-sticky]": {
position: "sticky",
zIndex: 1,
bg: "bg",
_after: {
content: '""',
position: "absolute",
pointerEvents: "none",
top: "0",
bottom: "-1px",
width: "32px",
},
},
"& [data-sticky=end]": {
_after: {
insetInlineEnd: "0",
translate: "100% 0",
shadow: "inset 8px 0px 8px -8px rgba(0, 0, 0, 0.16)",
},
},
"& [data-sticky=start]": {
_after: {
insetInlineStart: "0",
translate: "-100% 0",
shadow: "inset -8px 0px 8px -8px rgba(0, 0, 0, 0.16)",
},
},
"& thead tr": {
shadow: "0 1px 0 0 {colors.border}",
"&:has(th[data-sticky])": {
zIndex: 2,
},
},
}}
>
<Table.Header>
<Table.Row>
<Table.ColumnHeader>
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Besitzer</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausleihcode</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Startdatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Enddatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausleihdatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Rückgabedatum</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Erstellt am</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Ausgeliehene Artikel</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Notiz</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Aktionen</strong>
</Table.ColumnHeader>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Table.Header>
<Table.Body>
{items.map((item) => (
<Table.Row color={item.deleted ? "red" : "white"} key={item.id}>
<Table.Cell>{item.id}</Table.Cell>
<Table.Cell>{item.username}</Table.Cell>
<Table.Cell>
<Code>{item.loan_code}</Code>
</Table.Cell>
<Table.Cell>{formatDateTime(item.start_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.end_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.take_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.returned_date)}</Table.Cell>
<Table.Cell>{formatDateTime(item.created_at)}</Table.Cell>
<Table.Cell>{item.loaned_items_name.join(", ")}</Table.Cell>
<Table.Cell>{item.note}</Table.Cell>
<Table.Cell>
<Button
onClick={() =>
deleteLoan(item.id).then((response) => {
if (response.success) {
setItems(items.filter((i) => i.id !== item.id));
setError(
"success",
"Loan deleted",
"The loan has been successfully deleted.",
);
}
})
}
colorPalette="red"
size="sm"
ml={2}
>
<Trash2 />
</Button>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</Table.ScrollArea>
)}
</>
</Box>
);
};
+175
View File
@@ -0,0 +1,175 @@
import React from "react";
import {
Table,
Spinner,
Text,
VStack,
Heading,
Switch,
} from "@chakra-ui/react";
import MyAlert from "./myChakra/MyAlert";
import Cookies from "js-cookie";
import { useState, useEffect } from "react";
import { formatDateTime } from "@/utils/userFuncs";
import { API_BASE } from "@/config/api.config";
type Items = {
id: number;
function_name: string;
active: boolean;
entry_created_at: string;
updated_at: string | null;
};
const ServerConfig: React.FC = () => {
const [items, setItems] = useState<Items[]>([]);
const [errorStatus, setErrorStatus] = useState<"error" | "success">("error");
const [errorMessage, setErrorMessage] = useState("");
const [errorDsc, setErrorDsc] = useState("");
const [isError, setIsError] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [reload, setReload] = useState(false);
const handleSwitchChange = async (id: number, newState: boolean) => {
try {
const response = await fetch(
`${API_BASE}/api/admin/server-config/update?functionName=${encodeURIComponent(
items.find((item) => item.id === id)?.function_name || "",
)}&active=${newState}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
},
);
if (response.ok) {
setReload((prev) => !prev);
setError(
"success",
"Status updated",
"The function status was updated successfully.",
);
} else {
setError(
"error",
"Failed to update status",
"There is an error updating the function status.",
);
}
} catch (error) {
setError(
"error",
"Failed to update status",
"There is an error updating the function status.",
);
}
};
const setError = (
status: "error" | "success",
message: string,
description: string,
) => {
setIsError(false);
setErrorStatus(status);
setErrorMessage(message);
setErrorDsc(description);
setIsError(true);
};
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const response = await fetch(
`${API_BASE}/api/admin/server-config/all`,
{
method: "GET",
headers: {
Authorization: `Bearer ${Cookies.get("token")}`,
},
},
);
const data = await response.json();
return data.data;
} catch (error) {
setError("error", "Failed to fetch items", "There is an error");
} finally {
setIsLoading(false);
}
};
fetchData().then((data) => {
if (Array.isArray(data)) {
setItems(data);
}
});
}, [reload]);
return (
<>
<Heading marginBottom={4} size="2xl">
Server Konfiguration
</Heading>
{isError && (
<MyAlert
status={errorStatus}
description={errorDsc}
title={errorMessage}
/>
)}
{isLoading && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">Loading...</Text>
</VStack>
)}
<Table.Root size="sm" striped w="100%" style={{ tableLayout: "auto" }}>
<Table.Header>
<Table.Row>
<Table.ColumnHeader width="1%" whiteSpace="nowrap">
<strong>#</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Service Name</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Toggle</strong>
</Table.ColumnHeader>
<Table.ColumnHeader>
<strong>Eintrag erstellt am</strong>
</Table.ColumnHeader>
</Table.Row>
</Table.Header>
<Table.Body>
{items.map((item) => (
<Table.Row key={item.id}>
<Table.Cell whiteSpace="nowrap">{item.id}</Table.Cell>
<Table.Cell fontFamily="mono">{item.function_name}</Table.Cell>
<Table.Cell>
<Switch.Root
checked={item.active}
onCheckedChange={() =>
handleSwitchChange(item.id, !item.active)
}
>
<Switch.HiddenInput />
<Switch.Control>
<Switch.Thumb />
</Switch.Control>
<Switch.Label />
</Switch.Root>
</Table.Cell>
<Table.Cell whiteSpace="nowrap">
{formatDateTime(item.entry_created_at)}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table.Root>
</>
);
};
export default ServerConfig;
+7 -3
View File
@@ -8,9 +8,13 @@ export default defineConfig({
plugins: [react(), svgr(), tailwindcss(), tsconfigPaths()],
server: {
host: "0.0.0.0",
port: 8003,
watch: {
usePolling: true,
allowedHosts: ["admin.insta.the1s.de"],
port: 8103,
watch: { usePolling: true },
hmr: {
host: "admin.insta.the1s.de",
port: 8103,
protocol: "wss",
},
},
});
+3 -3
View File
@@ -1,11 +1,11 @@
{
"backend-info": {
"version": "v2.1.1 (dev)"
"version": "v2.2"
},
"frontend-info": {
"version": "v2.1.2 (dev)"
"version": "v2.2"
},
"admin-panel-info": {
"version": "v1.3.2 (dev)"
"version": "v1.4"
}
}
+74 -38
View File
@@ -1,21 +1,22 @@
{
"name": "backendv2",
"version": "1.0.0",
"version": "v2.1.1 (dev)",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "backendv2",
"version": "1.0.0",
"version": "v2.1.1 (dev)",
"license": "ISC",
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
"express": "^5.1.0",
"express-rate-limit": "^8.4.1",
"jose": "^6.0.12",
"mysql2": "^3.14.3",
"nodemailer": "^7.0.6"
"nodemailer": "^8.0.6"
}
},
"node_modules/accepts": {
@@ -53,29 +54,49 @@
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.0",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.6.3",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"raw-body": "^3.0.0",
"type-is": "^2.0.0"
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -349,6 +370,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz",
"integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/filelist": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@@ -509,24 +548,21 @@
"node": ">= 0.8"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -656,9 +692,9 @@
}
},
"node_modules/minimatch": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
@@ -731,9 +767,9 @@
}
},
"node_modules/nodemailer": {
"version": "7.0.10",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz",
"integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==",
"version": "8.0.6",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.6.tgz",
"integrity": "sha512-Nm2XeuDwwy2wi5A+8jPWwQwNzcjNjhWdE3pVLoXEusxJqCnAPAgnBGkSmiLknbnWuOF9qraRpYZjfxqtKZ4tPw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -791,9 +827,9 @@
}
},
"node_modules/path-to-regexp": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==",
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
@@ -820,9 +856,9 @@
}
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
+3 -2
View File
@@ -15,8 +15,9 @@
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
"express": "^5.1.0",
"express-rate-limit": "^8.4.1",
"jose": "^6.0.12",
"mysql2": "^3.14.3",
"nodemailer": "^7.0.6"
"nodemailer": "^8.0.6"
}
}
}
@@ -17,7 +17,10 @@ export const getAllLoans = async () => {
};
export const deleteLoanById = async (loanId) => {
const [result] = await pool.query("DELETE FROM loans WHERE id = ?", [loanId]);
const [result] = await pool.query(
"UPDATE loans SET deleted = true, deleted_admin = true WHERE id = ?",
[loanId],
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
@@ -0,0 +1,26 @@
import mysql from "mysql2";
import dotenv from "dotenv";
dotenv.config();
const pool = mysql
.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
.promise();
export const getAllFunctions = async () => {
const [rows] = await pool.query("SELECT * FROM functions");
return { success: true, data: rows };
};
export const updateFunctionStatus = async (functionName, active) => {
const [result] = await pool.query(
"UPDATE functions SET active = ? WHERE function_name = ?",
[active, functionName],
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
@@ -0,0 +1,50 @@
import express from "express";
import { authenticateAdmin } from "../../services/authentication.js";
const router = express.Router();
import dotenv from "dotenv";
dotenv.config();
// database funcs import
import {
getAllFunctions,
updateFunctionStatus,
} from "./database/serverConfMgmt.database.js";
// Route to get all functions and their statuses
router.get("/all", async (req, res) => {
try {
const result = await getAllFunctions();
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to fetch functions" });
}
} catch (error) {
res
.status(500)
.json({ message: "An error occurred", error: error.message });
}
});
// Route to update the status of a function
router.post("/update", async (req, res) => {
const functionName = req.query.functionName;
let active = req.query.active;
if (active === "false") {
active = 0;
} else if (active === "true") {
active = 1;
} else {
res.status(406).json({ message: "Got unexpected format" });
}
const result = await updateFunctionStatus(functionName, active);
if (result.success) {
res.status(200).json({ message: "Function status updated successfully" });
} else {
res.status(500).json({ message: "Failed to update function status" });
}
});
export default router;
+6
View File
@@ -1,9 +1,12 @@
import express from "express";
import { authenticate } from "../../services/authentication.js";
import { checkIfServiceIsActive } from "../../services/functions.js";
const router = express.Router();
import dotenv from "dotenv";
dotenv.config();
const loan_service = "Loan Service";
import {
getItemsFromDatabaseV2,
changeInSafeStateV2,
@@ -39,6 +42,7 @@ router.post("/change-state/:key/:itemId", authenticate, async (req, res) => {
router.get(
"/get-loan-by-code/:key/:loan_code",
authenticate,
checkIfServiceIsActive(loan_service),
async (req, res) => {
const loan_code = req.params.loan_code;
const result = await getLoanByCodeV2(loan_code);
@@ -54,6 +58,7 @@ router.get(
router.post(
"/set-return-date/:key/:loan_code",
authenticate,
checkIfServiceIsActive(loan_service),
async (req, res) => {
const loanCode = req.params.loan_code;
const result = await setReturnDateV2(loanCode);
@@ -69,6 +74,7 @@ router.post(
router.post(
"/set-take-date/:key/:loan_code",
authenticate,
checkIfServiceIsActive(loan_service),
async (req, res) => {
const loanCode = req.params.loan_code;
const result = await setTakeDateV2(loanCode);
@@ -234,6 +234,23 @@ export const getBorrowableItemsFromDatabase = async (
};
export const SETdeleteLoanFromDatabase = async (loanId) => {
const [checkIfdatesReturned] = await pool.query(
"SELECT take_date, returned_date FROM loans WHERE id = ? AND deleted = 0",
[loanId],
);
if (checkIfdatesReturned.length === 0) {
return { success: false, code: "LOAN_NOT_FOUND" };
}
const { take_date, returned_date } = checkIfdatesReturned[0];
const bothNull = take_date === null && returned_date === null;
const bothSet = take_date !== null && returned_date !== null;
if (!(bothNull || bothSet)) {
return { success: false, code: "LOAN_NOT_RETURNED" };
}
const [result] = await pool.query(
"UPDATE loans SET deleted = 1 WHERE id = ?;",
[loanId],
@@ -14,7 +14,7 @@ const pool = mysql
export const loginFunc = async (username, password) => {
const [result] = await pool.query(
"SELECT * FROM users WHERE username = ? AND password = ?",
[username, password]
[username, password],
);
if (result.length > 0) return { success: true, data: result[0] };
return { success: false };
@@ -40,7 +40,7 @@ export const changePassword = async (username, oldPassword, newPassword) => {
// get user current password
const [user] = await pool.query(
"SELECT * FROM users WHERE username = ? AND password = ?",
[username, oldPassword]
[username, oldPassword],
);
if (user.length === 0) return { success: false };
@@ -48,8 +48,16 @@ export const changePassword = async (username, oldPassword, newPassword) => {
const [result] = await pool.query(
"UPDATE users SET password = ? WHERE username = ?",
[newPassword, username]
[newPassword, username],
);
if (result.affectedRows > 0) return { success: true };
return { success: false };
};
export const getDeactivatedServices = async () => {
const [rows] = await pool.query("SELECT function_name FROM functions WHERE active = 0;");
if (rows.length > 0) {
return { success: true, data: rows };
}
return { success: false };
};
+192 -128
View File
@@ -1,8 +1,20 @@
import express from "express";
import { authenticate, generateToken } from "../../services/authentication.js";
const router = express.Router();
import {
checkIfServiceIsActive,
checkIfServiceIsActive2,
} from "../../services/functions.js";
// mailer imports
import { sendMail } from "../../services/mailer/send.js";
import { loanMail } from "../../services/mailer/templates/loan_created.js";
import dotenv from "dotenv";
dotenv.config();
const router = express.Router();
const loan_service = "Loan Service";
const loan_mailer_service = "Loan Mailer";
// database funcs import
import {
@@ -16,108 +28,135 @@ import {
setReturnDate,
setTakeDate,
} from "./database/loansMgmt.database.js";
import { sendMailLoan } from "./services/mailer.js";
router.post("/createLoan", authenticate, async (req, res) => {
try {
const { items, startDate, endDate, note } = req.body || {};
router.post(
"/createLoan",
checkIfServiceIsActive(loan_service),
authenticate,
async (req, res) => {
try {
const { items, startDate, endDate, note } = req.body || {};
if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ message: "Items array is required" });
}
if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ message: "Items array is required" });
}
// If dates are not provided, default to now .. +7 days
const start =
startDate ?? new Date().toISOString().slice(0, 19).replace("T", " ");
const end =
endDate ??
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 19)
.replace("T", " ");
// If dates are not provided, default to now .. +7 days
const start =
startDate ?? new Date().toISOString().slice(0, 19).replace("T", " ");
const end =
endDate ??
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 19)
.replace("T", " ");
// Coerce item IDs to numbers and filter invalids
const itemIds = items
.map((v) => Number(v))
.filter((n) => Number.isFinite(n));
// Coerce item IDs to numbers and filter invalids
const itemIds = items
.map((v) => Number(v))
.filter((n) => Number.isFinite(n));
if (itemIds.length === 0) {
return res.status(400).json({ message: "No valid item IDs provided" });
}
if (itemIds.length === 0) {
return res.status(400).json({ message: "No valid item IDs provided" });
}
const result = await createLoanInDatabase(
req.user.username,
start,
end,
note,
itemIds,
);
if (result.success) {
const mailInfo = await getLoanInfoWithID(result.data.id);
console.log(mailInfo);
sendMailLoan(
mailInfo.data.username,
mailInfo.data.loaned_items_name,
mailInfo.data.start_date,
mailInfo.data.end_date,
mailInfo.data.created_at,
mailInfo.data.note,
const result = await createLoanInDatabase(
req.user.username,
start,
end,
note,
itemIds,
);
return res.status(201).json({
message: "Loan created successfully",
loanId: result.data.id,
loanCode: result.data.loan_code,
});
if (result.success) {
if (await checkIfServiceIsActive2(loan_mailer_service)) {
const mailInfo = await getLoanInfoWithID(result.data.id);
console.log(mailInfo);
const { html, text } = loanMail(
req.user.first_name + " " + req.user.last_name,
mailInfo.data.loaned_items_name,
mailInfo.data.start_date,
mailInfo.data.end_date,
mailInfo.data.created_at,
mailInfo.data.note,
);
await sendMail({
to: process.env.MAIL_SENDEES,
subject: "Neue Ausleihe erstellt!",
html,
text,
});
}
return res.status(201).json({
message: "Loan created successfully",
loanId: result.data.id,
loanCode: result.data.loan_code,
});
}
if (result.code === "CONFLICT") {
return res
.status(409)
.json({ message: "Items not available in the selected period" });
}
if (result.code === "BAD_REQUEST") {
return res.status(400).json({ message: result.message });
}
return res.status(500).json({ message: "Failed to create loan" });
} catch (err) {
console.error("createLoan error:", err);
return res.status(500).json({ message: "Failed to create loan" });
}
},
);
if (result.code === "CONFLICT") {
return res
.status(409)
.json({ message: "Items not available in the selected period" });
router.get(
"/loans",
checkIfServiceIsActive(loan_service),
authenticate,
async (req, res) => {
const result = await getLoansFromDatabase(req.user.username);
if (result.success) {
res.status(200).json(result.data);
} else if (result.status) {
res.status(200).json([]);
} else {
res.status(500).json({ message: "Failed to fetch loans" });
}
},
);
if (result.code === "BAD_REQUEST") {
return res.status(400).json({ message: result.message });
router.post(
"/set-return-date/:loan_code",
checkIfServiceIsActive(loan_service),
authenticate,
async (req, res) => {
const loanCode = req.params.loan_code;
const result = await setReturnDate(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set return date" });
}
},
);
return res.status(500).json({ message: "Failed to create loan" });
} catch (err) {
console.error("createLoan error:", err);
return res.status(500).json({ message: "Failed to create loan" });
}
});
router.get("/loans", authenticate, async (req, res) => {
const result = await getLoansFromDatabase(req.user.username);
if (result.success) {
res.status(200).json(result.data);
} else if (result.status) {
res.status(200).json([]);
} else {
res.status(500).json({ message: "Failed to fetch loans" });
}
});
router.post("/set-return-date/:loan_code", authenticate, async (req, res) => {
const loanCode = req.params.loan_code;
const result = await setReturnDate(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set return date" });
}
});
router.post("/set-take-date/:loan_code", authenticate, async (req, res) => {
const loanCode = req.params.loan_code;
const result = await setTakeDate(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set take date" });
}
});
router.post(
"/set-take-date/:loan_code",
checkIfServiceIsActive(loan_service),
authenticate,
async (req, res) => {
const loanCode = req.params.loan_code;
const result = await setTakeDate(loanCode);
if (result.success) {
res.status(200).json({ data: result.data });
} else {
res.status(500).json({ message: "Failed to set take date" });
}
},
);
router.get("/all-items", authenticate, async (req, res) => {
const result = await getItems();
@@ -128,46 +167,71 @@ router.get("/all-items", authenticate, async (req, res) => {
}
});
router.delete("/delete-loan/:id", authenticate, async (req, res) => {
const loanId = req.params.id;
const result = await SETdeleteLoanFromDatabase(loanId);
if (result.success) {
res.status(200).json({ message: "Loan deleted successfully" });
} else {
res.status(500).json({ message: "Failed to delete loan" });
}
});
router.delete(
"/delete-loan/:id",
checkIfServiceIsActive(loan_service),
authenticate,
async (req, res) => {
const loanId = req.params.id;
const result = await SETdeleteLoanFromDatabase(loanId);
if (result.success) {
res.status(200).json({ message: "Loan deleted successfully" });
} else {
if (result.code === "LOAN_NOT_FOUND") {
res.status(404).json({ message: "Loan not found" });
}
router.get("/all-loans", authenticate, async (req, res) => {
const result = await getALLLoans();
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch loans" });
}
});
if (result.code === "LOAN_NOT_RETURNED") {
res.status(507).json({
message: "Cannot delete loan that has not been returned",
});
}
router.post("/borrowable-items", authenticate, async (req, res) => {
const { startDate, endDate } = req.body || {};
if (!startDate || !endDate) {
return res
.status(400)
.json({ message: "startDate and endDate are required" });
}
res.status(500).json({ message: "Failed to delete loan" });
}
},
);
const result = await getBorrowableItemsFromDatabase(
startDate,
endDate,
req.user.role,
);
if (result.success) {
// return the array directly for consistency with /items
return res.status(200).json(result.data);
} else {
return res
.status(500)
.json({ message: "Failed to fetch borrowable items" });
}
});
router.get(
"/all-loans",
checkIfServiceIsActive(loan_service),
authenticate,
async (req, res) => {
const result = await getALLLoans();
if (result.success) {
res.status(200).json(result.data);
} else {
res.status(500).json({ message: "Failed to fetch loans" });
}
},
);
router.post(
"/borrowable-items",
checkIfServiceIsActive(loan_service),
authenticate,
async (req, res) => {
const { startDate, endDate } = req.body || {};
if (!startDate || !endDate) {
return res
.status(400)
.json({ message: "startDate and endDate are required" });
}
const result = await getBorrowableItemsFromDatabase(
startDate,
endDate,
req.user.role,
);
if (result.success) {
// return the array directly for consistency with /items
return res.status(200).json(result.data);
} else {
return res
.status(500)
.json({ message: "Failed to fetch borrowable items" });
}
},
);
export default router;
-215
View File
@@ -1,215 +0,0 @@
import nodemailer from "nodemailer";
import dotenv from "dotenv";
dotenv.config();
const formatDateTime = (value) => {
if (value == null) return "N/A";
const toOut = (d) => {
if (!(d instanceof Date) || isNaN(d.getTime())) return "N/A";
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const hh = String(d.getHours()).padStart(2, "0");
const mi = String(d.getMinutes()).padStart(2, "0");
return `${dd}.${mm}.${yyyy} ${hh}:${mi} Uhr`;
};
if (value instanceof Date) return toOut(value);
if (typeof value === "number") return toOut(new Date(value));
const s = String(value).trim();
// Direct pattern: "YYYY-MM-DD[ T]HH:mm[:ss]"
const m = s.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::\d{2})?/);
if (m) {
const [, y, M, d, h, min] = m;
return `${d}.${M}.${y} ${h}:${min} Uhr`;
}
// ISO or other parseable formats
const dObj = new Date(s);
if (!isNaN(dObj.getTime())) return toOut(dObj);
return "N/A";
};
function buildLoanEmail({
user,
items,
startDate,
endDate,
createdDate,
note,
}) {
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>
<tr>
<td style="padding:10px 14px; color:#6b7280; vertical-align:top;">Notiz</td>
<td style="padding:10px 14px; font-weight:600; color:#111827;">${
note || "Keine Notiz"
}</td>
</tr>
</tbody>
</table>
<p style="margin:22px 0 0 0; font-size:14px;">
<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,
note,
}) {
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)}`,
`Notiz: ${note || "Keine Notiz"}`,
].join("\n");
}
export function sendMailLoan(
user,
items,
startDate,
endDate,
createdDate,
note,
) {
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,
note,
}),
html: buildLoanEmail({
user,
items,
startDate,
endDate,
createdDate,
note,
}),
});
console.log("Loan message sent:", info.messageId);
})();
}
@@ -1,43 +0,0 @@
import nodemailer from "nodemailer";
import dotenv from "dotenv";
dotenv.config();
export function sendMail(username, message) {
const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST,
port: process.env.MAIL_PORT,
secure: true,
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASSWORD,
},
});
(async () => {
const mailText = `Neue Kontaktanfrage im Ausleihsystem.\n\nBenutzername: ${username}\n\nNachricht:\n${message}`;
const mailHtml = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>Neue Nachricht im Ausleihsystem</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.5; color: #222;">
<h2>Neue Nachricht im Ausleihsystem</h2>
<p><strong>Benutzername:</strong> ${username}</p>
<p><strong>Nachricht:</strong></p>
<p style="white-space: pre-line;">${message}</p>
</body>
</html>`;
const info = await transporter.sendMail({
from: '"Ausleihsystem" <noreply@mcs-medien.de>',
to: process.env.MAIL_SENDEES_CONTACT,
subject: "Sie haben eine neue Nachricht!",
text: mailText,
html: mailHtml,
});
console.log("Contact message sent: %s", info.messageId);
})();
}
+85 -34
View File
@@ -1,48 +1,99 @@
import express from "express";
import { authenticate, generateToken } from "../../services/authentication.js";
import { checkIfServiceIsActive } from "../../services/functions.js";
const router = express.Router();
import dotenv from "dotenv";
dotenv.config();
const user_frontend_service = "User Frontend";
const contact_form_service = "Contact Form Service";
// database funcs import
import { loginFunc, changePassword } from "./database/userMgmt.database.js";
import { sendMail } from "./services/mailer_v2.js";
import {
loginFunc,
changePassword,
getDeactivatedServices,
} from "./database/userMgmt.database.js";
router.post("/login", async (req, res) => {
const result = await loginFunc(req.body.username, req.body.password);
// mailer imports
import { sendMail } from "../../services/mailer/send.js";
import { contactMail } from "../../services/mailer/templates/contact.js";
router.post(
"/login",
checkIfServiceIsActive(user_frontend_service),
async (req, res) => {
const result = await loginFunc(req.body.username, req.body.password);
if (result.success) {
const token = await generateToken({
username: result.data.username,
is_admin: result.data.is_admin,
first_name: result.data.first_name,
last_name: result.data.last_name,
role: result.data.role,
});
res.status(200).json({ message: "Login successful", token });
} else {
res.status(401).json({ message: "Invalid credentials" });
}
},
);
router.post(
"/change-password",
checkIfServiceIsActive(user_frontend_service),
authenticate,
async (req, res) => {
const oldPassword = req.body.oldPassword;
const newPassword = req.body.newPassword;
const username = req.user.username;
const result = await changePassword(username, oldPassword, newPassword);
if (result.success) {
res.status(200).json({ message: "Password changed successfully" });
} else {
res.status(500).json({ message: "Failed to change password" });
}
},
);
router.post(
"/contact",
checkIfServiceIsActive(contact_form_service),
authenticate,
async (req, res) => {
try {
const message = req.body?.message;
const username = req.user?.first_name + " " + req.user?.last_name;
if (!username || !message) {
return res
.status(400)
.json({ message: "Username and message are required" });
}
const { html, text } = contactMail({ username, message });
await sendMail({
to: process.env.MAIL_SENDEES_CONTACT,
subject: "Neue Nachricht!",
html,
text,
});
res.status(200).json({ message: "Contact message sent successfully" });
} catch (error) {
console.error("Failed to send contact mail:", error);
res.status(500).json({ message: "Failed to send contact message" });
}
},
);
router.get("/deactivated-services", authenticate, async (req, res) => {
const result = await getDeactivatedServices();
if (result.success) {
const token = await generateToken({
username: result.data.username,
is_admin: result.data.is_admin,
first_name: result.data.first_name,
last_name: result.data.last_name,
role: result.data.role,
});
res.status(200).json({ message: "Login successful", token });
res.status(200).json(result.data);
} else {
res.status(401).json({ message: "Invalid credentials" });
res.status(500).json({ message: "Failed to fetch deactivated services" });
}
});
router.post("/change-password", authenticate, async (req, res) => {
const oldPassword = req.body.oldPassword;
const newPassword = req.body.newPassword;
const username = req.user.username;
const result = await changePassword(username, oldPassword, newPassword);
if (result.success) {
res.status(200).json({ message: "Password changed successfully" });
} else {
res.status(500).json({ message: "Failed to change password" });
}
});
router.post("/contact", authenticate, async (req, res) => {
const message = req.body.message;
const username = req.user.username;
sendMail(username, message);
res.status(200).json({ message: "Contact message sent successfully" });
});
export default router;
+13 -1
View File
@@ -27,6 +27,7 @@ CREATE TABLE loans (
loaned_items_id json NOT NULL DEFAULT ('[]'),
loaned_items_name json NOT NULL DEFAULT ('[]'),
deleted bool NOT NULL DEFAULT false,
deleted_admin bool NOT NULL DEFAULT false,
note varchar(500) DEFAULT NULL,
PRIMARY KEY (id),
CHECK (loan_code REGEXP '^[0-9]{6}$')
@@ -54,4 +55,15 @@ CREATE TABLE apiKeys (
entry_created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
CHECK (api_key REGEXP '^[0-9]{8}$')
) ENGINE=InnoDB;
) ENGINE=InnoDB;
CREATE TABLE functions (
id INT NOT NULL AUTO_INCREMENT,
function_name VARCHAR(500) NOT NULL UNIQUE,
active BOOLEAN NOT NULL DEFAULT true,
entry_updated_at timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
entry_created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
) ENGINE=InnoDB;
INSERT INTO functions (function_name) VALUES ("Loan Mailer"), ("Loan Service"), ("Contact Form Service"), ("User Frontend"), ("API")
+34 -5
View File
@@ -1,8 +1,25 @@
import express from "express";
import cors from "cors";
import env from "dotenv";
import dotenv from "dotenv";
import info from "./info.json" assert { type: "json" };
import { authenticate } from "./services/authentication.js";
import { rateLimit } from "express-rate-limit";
dotenv.config();
const app = express();
const port = 8004;
const naasURL = process.env.NAAS_URL;
const limiter = rateLimit({
windowMs: 1 * 60 * 1000, // 1 minute
limit: 50, // Limit each IP to 50 requests per `window` (here, per 1 minute).
standardHeaders: "draft-8", // draft-6: `RateLimit-*` headers; draft-7 & draft-8: combined `RateLimit` header
legacyHeaders: false, // Disable the `X-RateLimit-*` headers.
ipv6Subnet: 56, // Set to 60 or 64 to be less aggressive, or 52 or 48 to be more aggressive
// store: ... , // Redis, Memcached, etc. See below.
});
app.use(limiter);
// frontend routes
import loansMgmtRouter from "./routes/app/loanMgmt.route.js";
@@ -14,14 +31,11 @@ import loanDataMgmtRouter from "./routes/admin/loanDataMgmt.route.js";
import itemDataMgmtRouter from "./routes/admin/itemDataMgmt.route.js";
import apiDataMgmtRouter from "./routes/admin/apiDataMgmt.route.js";
import userMgmtRouterADMIN from "./routes/admin/userMgmt.route.js";
import serverConfMgmtRouter from "./routes/admin/serverConfMgmt.route.js";
// API routes
import apiRouter from "./routes/api/api.route.js";
env.config();
const app = express();
const port = 8004;
app.use(cors());
// Body-Parser VOR den Routen registrieren
app.use(express.json({ limit: "10mb" }));
@@ -37,6 +51,7 @@ app.use("/api/admin/user-data", userDataMgmtRouter);
app.use("/api/admin/item-data", itemDataMgmtRouter);
app.use("/api/admin/api-data", apiDataMgmtRouter);
app.use("/api/admin/user-mgmt", userMgmtRouterADMIN);
app.use("/api/admin/server-config", serverConfMgmtRouter);
// API routes
app.use("/api", apiRouter);
@@ -47,6 +62,20 @@ app.listen(port, () => {
console.log(`Server is running on port: ${port}`);
});
app.get("/no", async (req, res) => {
try {
const response = await fetch(naasURL);
if (!response.ok) {
res.status(500).send("Request to no-as-a-service went wrong.");
}
const data = await response.json();
res.json(data);
} catch (error) {
console.error("Error communicating with no-as-a-service:", error);
res.status(500).send("Error communicating with no-as-a-service.");
}
});
app.get("/verify", authenticate, async (req, res) => {
res.status(200).json({ message: "Token is valid", user: req.user });
});
+18
View File
@@ -1,8 +1,12 @@
import { SignJWT, jwtVerify } from "jose";
import env from "dotenv";
import { verifyAPIKeyDB } from "./database.js";
import { checkIfServiceIsActive2 } from "./functions.js";
env.config();
const api_service = "API";
const user_frontend_service = "User Frontend";
const secretKey = process.env.SECRET_KEY;
if (!secretKey) {
throw new Error("Missing SECRET_KEY environment variable");
@@ -45,6 +49,13 @@ export async function authenticate(req, res, next) {
const apiKey = req.params.key;
if (authHeader) {
const serviceActive = await checkIfServiceIsActive2(user_frontend_service);
if (!serviceActive) {
return res
.status(503)
.json({ message: "User Frontend is currently unavailable." });
}
const parts = authHeader.split(" ");
const scheme = parts[0];
const token = parts[1];
@@ -61,6 +72,13 @@ export async function authenticate(req, res, next) {
return res.status(403).json({ message: "Present token invalid" }); // present token invalid
}
} else if (apiKey) {
const serviceActive = await checkIfServiceIsActive2(api_service);
if (!serviceActive) {
return res
.status(503)
.json({ message: "API Service is currently unavailable." });
}
try {
await verifyAPIKey(apiKey);
return next();
+42
View File
@@ -0,0 +1,42 @@
import mysql from "mysql2";
import dotenv from "dotenv";
dotenv.config();
const pool = mysql
.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
})
.promise();
export function checkIfServiceIsActive(service) {
return async (req, res, next) => {
const [result] = await pool.query(
"SELECT * FROM functions WHERE function_name = ? AND active = 1;",
[service],
);
if (result.length > 0) {
return next();
}
return res
.status(503)
.json({ message: `-${service}- is currently unavailable.` });
};
}
export async function checkIfServiceIsActive2(service) {
const [result] = await pool.query(
"SELECT * FROM functions WHERE function_name = ? AND active = 1;",
[service],
);
if (result.length > 0) {
return true;
}
return false;
}
+13
View File
@@ -0,0 +1,13 @@
import { transporter } from "./transporter.js";
export async function sendMail({ to, subject, text, html }) {
const info = await transporter.sendMail({
from: '"Ausleihsystem" <noreply@mcs-medien.de>',
to,
subject,
text,
html,
});
console.log("Mail sent:", info.messageId);
return info;
}
@@ -0,0 +1,76 @@
export function contactMail({ username, message }) {
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
const html = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
<title>Neue Nachricht</title>
</head>
<body style="margin:0;padding:0;background:#f2f4f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;color:#111827;-webkit-text-size-adjust:100%;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">Neue Kontaktanfrage im Ausleihsystem.</div>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#f2f4f7;">
<tr>
<td align="center" style="padding:32px 16px;">
<table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width:600px;width:100%;border-radius:14px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.06);">
<!-- Header -->
<tr>
<td style="background:${brand};padding:32px 30px 28px;">
<h1 style="margin:0;font-size:24px;color:#ffffff;font-weight:700;">Neue Nachricht</h1>
<p style="margin:8px 0 0;font-size:14px;color:rgba(255,255,255,0.85);">Eine neue Kontaktanfrage ist eingegangen.</p>
</td>
</tr>
<!-- Accent line -->
<tr>
<td style="height:3px;line-height:3px;font-size:1px;background:${brand};">&nbsp;</td>
</tr>
<!-- Content -->
<tr>
<td style="background:#ffffff;padding:28px 30px;">
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#fafbfc;border:1px solid #f3f4f6;border-radius:10px;border-collapse:collapse;">
<tr>
<td style="padding:14px 16px;color:#6b7280;font-size:13px;white-space:nowrap;vertical-align:top;border-bottom:1px solid #f3f4f6;">
Benutzername
</td>
<td style="padding:14px 16px;font-weight:600;color:#111827;font-size:14px;vertical-align:top;border-bottom:1px solid #f3f4f6;">
${username || "N/A"}
</td>
</tr>
<tr>
<td style="padding:14px 16px;color:#6b7280;font-size:13px;white-space:nowrap;vertical-align:top;">
Nachricht
</td>
<td style="padding:14px 16px;font-weight:600;color:#111827;font-size:14px;vertical-align:top;white-space:pre-line;">
${message || "N/A"}
</td>
</tr>
</table>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background:#f9fafb;padding:20px 30px;border-top:1px solid #e5e7eb;">
<p style="margin:0;font-size:12px;color:#9ca3af;text-align:center;line-height:1.6;">
Diese E-Mail wurde automatisch vom Ausleihsystem gesendet.<br>
Bitte antworten Sie nicht auf diese Nachricht.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
const text = `Neue Kontaktanfrage\n\nBenutzername: ${username}\nNachricht:\n${message}`;
return { html, text };
}
@@ -0,0 +1,124 @@
const formatDateTime = (value) => {
if (value == null) return "N/A";
const d = value instanceof Date ? value : new Date(value);
if (isNaN(d.getTime())) return "N/A";
return (
d.toLocaleString("de-DE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
}) + " Uhr"
);
};
export function loanMail(user, items, startDate, endDate, createdDate, note) {
const brand = process.env.MAIL_BRAND_COLOR || "#0ea5e9";
const itemsHtml =
Array.isArray(items) && items.length
? items
.map(
(i) =>
`<span style="display:inline-block;background:#f0f9ff;color:#0369a1;padding:4px 12px;margin:2px 4px 2px 0;border-radius:20px;font-size:13px;font-weight:500;">${i}</span>`,
)
.join(" ")
: '<span style="color:#9ca3af;">Keine Gegenst&auml;nde</span>';
const row = (label, value, isLast = false) => `
<tr>
<td style="padding:14px 16px;color:#6b7280;font-size:13px;white-space:nowrap;vertical-align:top;${isLast ? "" : "border-bottom:1px solid #f3f4f6;"}">
${label}
</td>
<td style="padding:14px 16px;font-weight:600;color:#111827;font-size:14px;vertical-align:top;${isLast ? "" : "border-bottom:1px solid #f3f4f6;"}">
${value}
</td>
</tr>`;
const html = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light">
<title>Neue Ausleihe</title>
</head>
<body style="margin:0;padding:0;background:#f2f4f7;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;color:#111827;-webkit-text-size-adjust:100%;">
<div style="display:none;max-height:0;overflow:hidden;opacity:0;">Neue Ausleihe erstellt Details zur Buchung.</div>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#f2f4f7;">
<tr>
<td align="center" style="padding:32px 16px;">
<table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width:600px;width:100%;border-radius:14px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,0.06);">
<!-- Header -->
<tr>
<td style="background:${brand};padding:32px 30px 28px;">
<h1 style="margin:0;font-size:24px;color:#ffffff;font-weight:700;">Neue Ausleihe</h1>
<p style="margin:8px 0 0;font-size:14px;color:rgba(255,255,255,0.85);">Es wurde soeben eine neue Ausleihe im System angelegt.</p>
</td>
</tr>
<!-- Accent line -->
<tr>
<td style="background:#ffffff;padding:0;height:3px;line-height:3px;font-size:1px;background:${brand};">&nbsp;</td>
</tr>
<!-- Details -->
<tr>
<td style="background:#ffffff;padding:28px 30px 10px;">
<p style="margin:0 0 14px;font-size:11px;font-weight:700;color:#9ca3af;letter-spacing:1.5px;text-transform:uppercase;">Details zur Ausleihe</p>
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#fafbfc;border:1px solid #f3f4f6;border-radius:10px;border-collapse:collapse;">
${row("Benutzer", user || "N/A")}
${row("Gegenst&auml;nde", itemsHtml)}
${row("Startdatum", formatDateTime(startDate))}
${row("Enddatum", formatDateTime(endDate))}
${row("Erstellt am", formatDateTime(createdDate))}
${row("Notiz", note || '<span style="color:#9ca3af;">Keine Notiz</span>', true)}
</table>
</td>
</tr>
<!-- Button -->
<tr>
<td style="background:#ffffff;padding:20px 30px 32px;" align="center">
<a href="https://admin.insta.the1s.de/api" target="_blank" rel="noopener noreferrer"
style="display:inline-block;background:${brand};color:#ffffff;text-decoration:none;padding:13px 28px;border-radius:8px;font-weight:600;font-size:15px;">
Ausleihe ansehen &rarr;
</a>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background:#f9fafb;padding:20px 30px;border-top:1px solid #e5e7eb;">
<p style="margin:0;font-size:12px;color:#9ca3af;text-align:center;line-height:1.6;">
Diese E-Mail wurde automatisch vom Ausleihsystem gesendet.<br>
Bitte antworten Sie nicht auf diese Nachricht.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
const itemsText = Array.isArray(items) ? items.join(", ") : "N/A";
const text = [
"Neue Ausleihe erstellt",
"-".repeat(30),
`Benutzer: ${user || "N/A"}`,
`Gegenstaende: ${itemsText}`,
`Start: ${formatDateTime(startDate)}`,
`Ende: ${formatDateTime(endDate)}`,
`Erstellt: ${formatDateTime(createdDate)}`,
`Notiz: ${note || "Keine Notiz"}`,
"",
"-> https://admin.insta.the1s.de/api",
].join("\n");
return { html, text };
}
+13
View File
@@ -0,0 +1,13 @@
import nodemailer from "nodemailer";
import dotenv from "dotenv";
dotenv.config();
export const transporter = nodemailer.createTransport({
host: process.env.MAIL_HOST,
port: process.env.MAIL_PORT,
secure: true,
auth: {
user: process.env.MAIL_USER,
pass: process.env.MAIL_PASSWORD,
},
});
+29
View File
@@ -0,0 +1,29 @@
# Changelog for upcoming version: vX.X
Introduction
## New features
-
## Improvements
-
## Fixed bugs
-
---
## New version numbers
**Backend:** vX.X
**Frontend:** vX.X
**Admin panel:** vX.X
---
-[Theis](https://portfolio-theis.de)
+29 -16
View File
@@ -1,23 +1,23 @@
services:
# usr-frontend_v2:
# container_name: borrow_system-usr-frontend
# build: ./FrontendV2
# ports:
# - "8001:80"
# restart: unless-stopped
usr-frontend_v2:
container_name: borrow_system-usr-frontend
networks:
- proxynet
build: ./FrontendV2
restart: unless-stopped
# admin-frontend:
# container_name: borrow_system-admin-frontend
# build: ./admin
# ports:
# - "8003:80"
# restart: unless-stopped
admin-frontend:
container_name: borrow_system-admin-frontend
networks:
- proxynet
build: ./admin
restart: unless-stopped
backend_v2:
container_name: borrow_system-backend_v2
networks:
- proxynet
build: ./backendV2
ports:
- "8004:8004"
environment:
NODE_ENV: production
DB_HOST: mysql_v2
@@ -30,6 +30,8 @@ services:
mysql_v2:
container_name: borrow_system-mysql-v2
networks:
- proxynet
image: mysql:8.0
restart: unless-stopped
environment:
@@ -39,9 +41,20 @@ services:
volumes:
- mysql-v2-data:/var/lib/mysql
- ./mysql-timezone.cnf:/etc/mysql/conf.d/timezone.cnf:ro
ports:
- "3310:3306"
no-as-a-service:
container_name: borrow_system-naas
networks:
- proxynet
build:
context: ./no-as-a-service
dockerfile: Dockerfile
restart: always
volumes:
mysql-data:
mysql-v2-data:
networks:
proxynet:
external: true
+1
Submodule no-as-a-service added at 764062a307