Compare commits

...

10 Commits

34 changed files with 1131 additions and 502 deletions
+3 -3
View File
@@ -5224,9 +5224,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.11",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.11.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "integrity": "sha512-5dDj8+lmvA8XB78SmzGI8NlQoksv7IfutGWeVZxiixHbO+p4LDPT3wuG/D9sM/wrjZZ9I+Siy/e117vbFPxSZg==",
"funding": [ "funding": [
{ {
"type": "opencollective", "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>
)}
</>
);
};
+1
View File
@@ -69,6 +69,7 @@ export const Header = () => {
className="mb-6" className="mb-6"
position="relative" position="relative"
pr={{ base: 10, md: 0 }} // Platz für den Mobile-Button rechts pr={{ base: 10, md: 0 }} // Platz für den Mobile-Button rechts
marginBottom={1}
> >
{/* Mobile: Drei-Punkte-Button, vertikal zentriert im Header */} {/* Mobile: Drei-Punkte-Button, vertikal zentriert im Header */}
<Box <Box
+27
View File
@@ -5,6 +5,8 @@ import {
Alert, Alert,
Container, Container,
Text, Text,
VStack,
Spinner,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useState } from "react"; import { useState } from "react";
@@ -21,9 +23,21 @@ interface Alert {
export const ContactPage = () => { export const ContactPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [isSending, setIsSending] = useState(false);
const [alert, setAlert] = useState<Alert | null>(null); const [alert, setAlert] = useState<Alert | null>(null);
const sendMessage = async () => { 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 // Logic to send the message
const result = await fetch(`${API_BASE}/api/users/contact`, { const result = await fetch(`${API_BASE}/api/users/contact`, {
method: "POST", method: "POST",
@@ -42,6 +56,12 @@ export const ContactPage = () => {
text: t("contactPage_successText"), text: t("contactPage_successText"),
}); });
setMessage(""); setMessage("");
} else if (result.status === 503) {
setAlert({
type: "error",
headline: t("serviceDeactivatedHeadline"),
text: t("contactPage_serviceDeactivatedText"),
});
} else { } else {
setAlert({ setAlert({
type: "error", type: "error",
@@ -49,6 +69,7 @@ export const ContactPage = () => {
text: t("contactPage_errorText"), text: t("contactPage_errorText"),
}); });
} }
setIsSending(false);
}; };
return ( return (
@@ -78,6 +99,12 @@ export const ContactPage = () => {
</Alert.Content> </Alert.Content>
</Alert.Root> </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> <Button onClick={sendMessage}>{t("contactPage_sendButton")}</Button>
</Container> </Container>
); );
+13 -9
View File
@@ -19,6 +19,7 @@ import { createLoan } from "@/utils/Fetcher";
import { Header } from "@/components/Header"; import { Header } from "@/components/Header";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { approvalAnimation } from "@/components/dotLottie"; import { approvalAnimation } from "@/components/dotLottie";
import { DeactivatedServices } from "@/components/DeactivatedServices";
export interface User { export interface User {
username: string; username: string;
@@ -71,6 +72,7 @@ export const HomePage = () => {
)} )}
<Container className="px-6 sm:px-8 pt-10"> <Container className="px-6 sm:px-8 pt-10">
<Header /> <Header />
<DeactivatedServices />
{isMsg && ( {isMsg && (
<MyAlert <MyAlert
status={msgStatus} status={msgStatus}
@@ -131,12 +133,6 @@ export const HomePage = () => {
> >
{t("get-borrowable-items")} {t("get-borrowable-items")}
</Button> </Button>
{isLoadingA && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">{t("loading")}</Text>
</VStack>
)}
{borrowableItems.length > 0 && ( {borrowableItems.length > 0 && (
<Table.ScrollArea borderWidth="1px" rounded="md"> <Table.ScrollArea borderWidth="1px" rounded="md">
<Table.Root size="sm" stickyHeader> <Table.Root size="sm" stickyHeader>
@@ -189,9 +185,11 @@ export const HomePage = () => {
)} )}
{selectedItems.length >= 1 && ( {selectedItems.length >= 1 && (
<Button <Button
onClick={() => onClick={() => {
setIsLoadingA(true);
createLoan(selectedItems, startDate, endDate, note).then( createLoan(selectedItems, startDate, endDate, note).then(
(response) => { (response) => {
setIsLoadingA(false);
if (response.status === "error") { if (response.status === "error") {
setMsgStatus("error"); setMsgStatus("error");
setMsgTitle(response.title || t("error")); setMsgTitle(response.title || t("error"));
@@ -207,12 +205,18 @@ export const HomePage = () => {
setMsgDescription(t("loan-success")); setMsgDescription(t("loan-success"));
setIsMsg(true); setIsMsg(true);
}, },
) );
} }}
> >
{t("create-loan")} {t("create-loan")}
</Button> </Button>
)} )}
{isLoadingA && (
<VStack colorPalette="teal">
<Spinner color="colorPalette.600" />
<Text color="colorPalette.600">{t("loading")}</Text>
</VStack>
)}
</Stack> </Stack>
</Container> </Container>
</> </>
+10
View File
@@ -79,6 +79,16 @@ const Landingpage: React.FC = () => {
Authorization: `Bearer ${Cookies.get("token")}`, 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(); const loanData = await loanRes.json();
if (Array.isArray(loanData)) { if (Array.isArray(loanData)) {
setLoans(loanData); setLoans(loanData);
+7
View File
@@ -52,6 +52,13 @@ export const MyLoansPage = () => {
}); });
if (!res.ok) { if (!res.ok) {
if (res.status === 503) {
setMsgStatus("error");
setMsgTitle(t("serviceDeactivatedHeadline"));
setMsgDescription(t("loan_page_serviceDeactivatedText"));
setIsMsg(true);
return;
}
setMsgStatus("error"); setMsgStatus("error");
setMsgTitle(t("error")); setMsgTitle(t("error"));
setMsgDescription(t("error-fetching-loans")); setMsgDescription(t("error-fetching-loans"));
+20
View File
@@ -17,6 +17,16 @@ export const getBorrowableItems = async (
}); });
if (!response.ok) { 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 { return {
data: null, data: null,
status: "error", status: "error",
@@ -60,6 +70,16 @@ export const createLoan = async (
}); });
if (!response.ok) { 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 { return {
data: null, data: null,
status: "error", status: "error",
+8 -1
View File
@@ -94,5 +94,12 @@
"naas-error": "Fehler mit no-as-a-service", "naas-error": "Fehler mit no-as-a-service",
"naas-error-desc": "Ein Fehler ist beim Kommunizieren mit no-as-a-service aufgetreten.", "naas-error-desc": "Ein Fehler ist beim Kommunizieren mit no-as-a-service aufgetreten.",
"naas-header": "Eine gute Möglichkeit, nein zu sagen...", "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." "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."
} }
+8 -1
View File
@@ -94,5 +94,12 @@
"naas-error": "Error with no-as-a-service", "naas-error": "Error with no-as-a-service",
"naas-error-desc": "An error occurred while communicating 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...", "naas-header": "A good way to say no...",
"error-deleting-loan-507": "The loan cannot be deleted because it has not been returned yet." "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."
} }
+3
View File
@@ -7,6 +7,7 @@ import UserTable from "../components/UserTable";
import ItemTable from "../components/ItemTable"; import ItemTable from "../components/ItemTable";
import LoanTable from "../components/LoanTable"; import LoanTable from "../components/LoanTable";
import APIKeyTable from "@/components/APIKeyTable"; import APIKeyTable from "@/components/APIKeyTable";
import ServerConfig from "@/components/ServerConfig";
import { MoveLeft } from "lucide-react"; import { MoveLeft } from "lucide-react";
type DashboardProps = { type DashboardProps = {
@@ -44,6 +45,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
viewSchliessfaecher={() => setActiveView("Schließfächer")} viewSchliessfaecher={() => setActiveView("Schließfächer")}
viewUser={() => setActiveView("User")} viewUser={() => setActiveView("User")}
viewAPI={() => setActiveView("API")} viewAPI={() => setActiveView("API")}
viewConfig={() => setActiveView("Server Konfiguration")}
/> />
<Box flex="1" display="flex" flexDirection="column"> <Box flex="1" display="flex" flexDirection="column">
<Flex <Flex
@@ -88,6 +90,7 @@ const Dashboard: React.FC<DashboardProps> = ({ onLogout }) => {
{activeView === "Ausleihen" && <LoanTable />} {activeView === "Ausleihen" && <LoanTable />}
{activeView === "Gegenstände" && <ItemTable />} {activeView === "Gegenstände" && <ItemTable />}
{activeView === "API" && <APIKeyTable />} {activeView === "API" && <APIKeyTable />}
{activeView === "Server Konfiguration" && <ServerConfig />}
</Box> </Box>
</Box> </Box>
</Flex> </Flex>
+11
View File
@@ -9,6 +9,7 @@ type SidebarProps = {
viewSchliessfaecher: () => void; viewSchliessfaecher: () => void;
viewUser: () => void; viewUser: () => void;
viewAPI: () => void; viewAPI: () => void;
viewConfig: () => void;
}; };
const Sidebar: React.FC<SidebarProps> = ({ const Sidebar: React.FC<SidebarProps> = ({
@@ -16,6 +17,7 @@ const Sidebar: React.FC<SidebarProps> = ({
viewGegenstaende, viewGegenstaende,
viewUser, viewUser,
viewAPI, viewAPI,
viewConfig
}) => { }) => {
const [info, setInfo] = useState<any>(null); const [info, setInfo] = useState<any>(null);
@@ -83,6 +85,15 @@ const Sidebar: React.FC<SidebarProps> = ({
> >
API Keys API Keys
</Link> </Link>
<Link
px={3}
py={2}
rounded="md"
_hover={{ bg: "gray.700", textDecoration: "none" }}
onClick={viewConfig}
>
Server Konfiguration
</Link>
</VStack> </VStack>
<Box mt="auto" pt={8} fontSize="xs" color="gray.500"> <Box mt="auto" pt={8} fontSize="xs" color="gray.500">
+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;
+1 -1
View File
@@ -6,6 +6,6 @@
"version": "v2.2 (dev)" "version": "v2.2 (dev)"
}, },
"admin-panel-info": { "admin-panel-info": {
"version": "v1.3.2 (dev)" "version": "v1.4(dev)"
} }
} }
+74 -38
View File
@@ -1,21 +1,22 @@
{ {
"name": "backendv2", "name": "backendv2",
"version": "1.0.0", "version": "v2.1.1 (dev)",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "backendv2", "name": "backendv2",
"version": "1.0.0", "version": "v2.1.1 (dev)",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.1.0", "express": "^5.1.0",
"express-rate-limit": "^8.4.1",
"jose": "^6.0.12", "jose": "^6.0.12",
"mysql2": "^3.14.3", "mysql2": "^3.14.3",
"nodemailer": "^7.0.6" "nodemailer": "^8.0.6"
} }
}, },
"node_modules/accepts": { "node_modules/accepts": {
@@ -53,29 +54,49 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/body-parser": { "node_modules/body-parser": {
"version": "2.2.0", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"bytes": "^3.1.2", "bytes": "^3.1.2",
"content-type": "^1.0.5", "content-type": "^1.0.5",
"debug": "^4.4.0", "debug": "^4.4.3",
"http-errors": "^2.0.0", "http-errors": "^2.0.0",
"iconv-lite": "^0.6.3", "iconv-lite": "^0.7.0",
"on-finished": "^2.4.1", "on-finished": "^2.4.1",
"qs": "^6.14.0", "qs": "^6.14.1",
"raw-body": "^3.0.0", "raw-body": "^3.0.1",
"type-is": "^2.0.0" "type-is": "^2.0.1"
}, },
"engines": { "engines": {
"node": ">=18" "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": { "node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
@@ -349,6 +370,24 @@
"url": "https://opencollective.com/express" "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": { "node_modules/filelist": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz",
@@ -509,24 +548,21 @@
"node": ">= 0.8" "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": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -656,9 +692,9 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "5.1.6", "version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"brace-expansion": "^2.0.1" "brace-expansion": "^2.0.1"
@@ -731,9 +767,9 @@
} }
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "7.0.10", "version": "8.0.6",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.10.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.6.tgz",
"integrity": "sha512-Us/Se1WtT0ylXgNFfyFSx4LElllVLJXQjWi2Xz17xWw7amDKO2MLtFnVp1WACy7GkVGs+oBlRopVNUzlrGSw1w==", "integrity": "sha512-Nm2XeuDwwy2wi5A+8jPWwQwNzcjNjhWdE3pVLoXEusxJqCnAPAgnBGkSmiLknbnWuOF9qraRpYZjfxqtKZ4tPw==",
"license": "MIT-0", "license": "MIT-0",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@@ -791,9 +827,9 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "8.3.0", "version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT", "license": "MIT",
"funding": { "funding": {
"type": "opencollective", "type": "opencollective",
@@ -820,9 +856,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.0", "version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "side-channel": "^1.1.0"
+3 -2
View File
@@ -15,8 +15,9 @@
"dotenv": "^17.2.1", "dotenv": "^17.2.1",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.1.0", "express": "^5.1.0",
"express-rate-limit": "^8.4.1",
"jose": "^6.0.12", "jose": "^6.0.12",
"mysql2": "^3.14.3", "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) => { 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 }; if (result.affectedRows > 0) return { success: true };
return { success: false }; 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 express from "express";
import { authenticate } from "../../services/authentication.js"; import { authenticate } from "../../services/authentication.js";
import { checkIfServiceIsActive } from "../../services/functions.js";
const router = express.Router(); const router = express.Router();
import dotenv from "dotenv"; import dotenv from "dotenv";
dotenv.config(); dotenv.config();
const loan_service = "Loan Service";
import { import {
getItemsFromDatabaseV2, getItemsFromDatabaseV2,
changeInSafeStateV2, changeInSafeStateV2,
@@ -39,6 +42,7 @@ router.post("/change-state/:key/:itemId", authenticate, async (req, res) => {
router.get( router.get(
"/get-loan-by-code/:key/:loan_code", "/get-loan-by-code/:key/:loan_code",
authenticate, authenticate,
checkIfServiceIsActive(loan_service),
async (req, res) => { async (req, res) => {
const loan_code = req.params.loan_code; const loan_code = req.params.loan_code;
const result = await getLoanByCodeV2(loan_code); const result = await getLoanByCodeV2(loan_code);
@@ -54,6 +58,7 @@ router.get(
router.post( router.post(
"/set-return-date/:key/:loan_code", "/set-return-date/:key/:loan_code",
authenticate, authenticate,
checkIfServiceIsActive(loan_service),
async (req, res) => { async (req, res) => {
const loanCode = req.params.loan_code; const loanCode = req.params.loan_code;
const result = await setReturnDateV2(loanCode); const result = await setReturnDateV2(loanCode);
@@ -69,6 +74,7 @@ router.post(
router.post( router.post(
"/set-take-date/:key/:loan_code", "/set-take-date/:key/:loan_code",
authenticate, authenticate,
checkIfServiceIsActive(loan_service),
async (req, res) => { async (req, res) => {
const loanCode = req.params.loan_code; const loanCode = req.params.loan_code;
const result = await setTakeDateV2(loanCode); const result = await setTakeDateV2(loanCode);
@@ -14,7 +14,7 @@ const pool = mysql
export const loginFunc = async (username, password) => { export const loginFunc = async (username, password) => {
const [result] = await pool.query( const [result] = await pool.query(
"SELECT * FROM users WHERE username = ? AND password = ?", "SELECT * FROM users WHERE username = ? AND password = ?",
[username, password] [username, password],
); );
if (result.length > 0) return { success: true, data: result[0] }; if (result.length > 0) return { success: true, data: result[0] };
return { success: false }; return { success: false };
@@ -40,7 +40,7 @@ export const changePassword = async (username, oldPassword, newPassword) => {
// get user current password // get user current password
const [user] = await pool.query( const [user] = await pool.query(
"SELECT * FROM users WHERE username = ? AND password = ?", "SELECT * FROM users WHERE username = ? AND password = ?",
[username, oldPassword] [username, oldPassword],
); );
if (user.length === 0) return { success: false }; if (user.length === 0) return { success: false };
@@ -48,8 +48,16 @@ export const changePassword = async (username, oldPassword, newPassword) => {
const [result] = await pool.query( const [result] = await pool.query(
"UPDATE users SET password = ? WHERE username = ?", "UPDATE users SET password = ? WHERE username = ?",
[newPassword, username] [newPassword, username],
); );
if (result.affectedRows > 0) return { success: true }; if (result.affectedRows > 0) return { success: true };
return { success: false }; 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 -138
View File
@@ -1,8 +1,20 @@
import express from "express"; import express from "express";
import { authenticate, generateToken } from "../../services/authentication.js"; 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"; import dotenv from "dotenv";
dotenv.config(); dotenv.config();
const router = express.Router();
const loan_service = "Loan Service";
const loan_mailer_service = "Loan Mailer";
// database funcs import // database funcs import
import { import {
@@ -16,108 +28,135 @@ import {
setReturnDate, setReturnDate,
setTakeDate, setTakeDate,
} from "./database/loansMgmt.database.js"; } from "./database/loansMgmt.database.js";
import { sendMailLoan } from "./services/mailer.js";
router.post("/createLoan", authenticate, async (req, res) => { router.post(
try { "/createLoan",
const { items, startDate, endDate, note } = req.body || {}; checkIfServiceIsActive(loan_service),
authenticate,
async (req, res) => {
try {
const { items, startDate, endDate, note } = req.body || {};
if (!Array.isArray(items) || items.length === 0) { if (!Array.isArray(items) || items.length === 0) {
return res.status(400).json({ message: "Items array is required" }); return res.status(400).json({ message: "Items array is required" });
} }
// If dates are not provided, default to now .. +7 days // If dates are not provided, default to now .. +7 days
const start = const start =
startDate ?? new Date().toISOString().slice(0, 19).replace("T", " "); startDate ?? new Date().toISOString().slice(0, 19).replace("T", " ");
const end = const end =
endDate ?? endDate ??
new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
.toISOString() .toISOString()
.slice(0, 19) .slice(0, 19)
.replace("T", " "); .replace("T", " ");
// Coerce item IDs to numbers and filter invalids // Coerce item IDs to numbers and filter invalids
const itemIds = items const itemIds = items
.map((v) => Number(v)) .map((v) => Number(v))
.filter((n) => Number.isFinite(n)); .filter((n) => Number.isFinite(n));
if (itemIds.length === 0) { if (itemIds.length === 0) {
return res.status(400).json({ message: "No valid item IDs provided" }); return res.status(400).json({ message: "No valid item IDs provided" });
} }
const result = await createLoanInDatabase( const result = await createLoanInDatabase(
req.user.username, req.user.username,
start, start,
end, end,
note, note,
itemIds, 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,
); );
return res.status(201).json({ if (result.success) {
message: "Loan created successfully", if (await checkIfServiceIsActive2(loan_mailer_service)) {
loanId: result.data.id, const mailInfo = await getLoanInfoWithID(result.data.id);
loanCode: result.data.loan_code, 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") { router.get(
return res "/loans",
.status(409) checkIfServiceIsActive(loan_service),
.json({ message: "Items not available in the selected period" }); 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") { router.post(
return res.status(400).json({ message: result.message }); "/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" }); router.post(
} catch (err) { "/set-take-date/:loan_code",
console.error("createLoan error:", err); checkIfServiceIsActive(loan_service),
return res.status(500).json({ message: "Failed to create loan" }); authenticate,
} async (req, res) => {
}); const loanCode = req.params.loan_code;
const result = await setTakeDate(loanCode);
router.get("/loans", authenticate, async (req, res) => { if (result.success) {
const result = await getLoansFromDatabase(req.user.username); res.status(200).json({ data: result.data });
if (result.success) { } else {
res.status(200).json(result.data); res.status(500).json({ message: "Failed to set take date" });
} 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.get("/all-items", authenticate, async (req, res) => { router.get("/all-items", authenticate, async (req, res) => {
const result = await getItems(); const result = await getItems();
@@ -128,56 +167,71 @@ router.get("/all-items", authenticate, async (req, res) => {
} }
}); });
router.delete("/delete-loan/:id", authenticate, async (req, res) => { router.delete(
const loanId = req.params.id; "/delete-loan/:id",
const result = await SETdeleteLoanFromDatabase(loanId); checkIfServiceIsActive(loan_service),
if (result.success) { authenticate,
res.status(200).json({ message: "Loan deleted successfully" }); async (req, res) => {
} else { const loanId = req.params.id;
if (result.code === "LOAN_NOT_FOUND") { const result = await SETdeleteLoanFromDatabase(loanId);
res.status(404).json({ message: "Loan not found" }); 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" });
}
if (result.code === "LOAN_NOT_RETURNED") {
res.status(507).json({
message: "Cannot delete loan that has not been returned",
});
}
res.status(500).json({ message: "Failed to delete loan" });
}
},
);
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" });
} }
if (result.code === "LOAN_NOT_RETURNED") { const result = await getBorrowableItemsFromDatabase(
res.status(507).json({ startDate,
message: "Cannot delete loan that has not been returned", 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" });
} }
},
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; 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 express from "express";
import { authenticate, generateToken } from "../../services/authentication.js"; import { authenticate, generateToken } from "../../services/authentication.js";
import { checkIfServiceIsActive } from "../../services/functions.js";
const router = express.Router(); const router = express.Router();
import dotenv from "dotenv"; import dotenv from "dotenv";
dotenv.config(); dotenv.config();
const user_frontend_service = "User Frontend";
const contact_form_service = "Contact Form Service";
// database funcs import // database funcs import
import { loginFunc, changePassword } from "./database/userMgmt.database.js"; import {
import { sendMail } from "./services/mailer_v2.js"; loginFunc,
changePassword,
getDeactivatedServices,
} from "./database/userMgmt.database.js";
router.post("/login", async (req, res) => { // mailer imports
const result = await loginFunc(req.body.username, req.body.password); 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) { if (result.success) {
const token = await generateToken({ res.status(200).json(result.data);
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 { } 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; export default router;
+13 -1
View File
@@ -27,6 +27,7 @@ CREATE TABLE loans (
loaned_items_id json NOT NULL DEFAULT ('[]'), loaned_items_id json NOT NULL DEFAULT ('[]'),
loaned_items_name json NOT NULL DEFAULT ('[]'), loaned_items_name json NOT NULL DEFAULT ('[]'),
deleted bool NOT NULL DEFAULT false, deleted bool NOT NULL DEFAULT false,
deleted_admin bool NOT NULL DEFAULT false,
note varchar(500) DEFAULT NULL, note varchar(500) DEFAULT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
CHECK (loan_code REGEXP '^[0-9]{6}$') CHECK (loan_code REGEXP '^[0-9]{6}$')
@@ -54,4 +55,15 @@ CREATE TABLE apiKeys (
entry_created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP, entry_created_at TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id), PRIMARY KEY (id),
CHECK (api_key REGEXP '^[0-9]{8}$') 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")
+19 -5
View File
@@ -3,6 +3,23 @@ import cors from "cors";
import dotenv from "dotenv"; import dotenv from "dotenv";
import info from "./info.json" assert { type: "json" }; import info from "./info.json" assert { type: "json" };
import { authenticate } from "./services/authentication.js"; 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 // frontend routes
import loansMgmtRouter from "./routes/app/loanMgmt.route.js"; import loansMgmtRouter from "./routes/app/loanMgmt.route.js";
@@ -14,15 +31,11 @@ import loanDataMgmtRouter from "./routes/admin/loanDataMgmt.route.js";
import itemDataMgmtRouter from "./routes/admin/itemDataMgmt.route.js"; import itemDataMgmtRouter from "./routes/admin/itemDataMgmt.route.js";
import apiDataMgmtRouter from "./routes/admin/apiDataMgmt.route.js"; import apiDataMgmtRouter from "./routes/admin/apiDataMgmt.route.js";
import userMgmtRouterADMIN from "./routes/admin/userMgmt.route.js"; import userMgmtRouterADMIN from "./routes/admin/userMgmt.route.js";
import serverConfMgmtRouter from "./routes/admin/serverConfMgmt.route.js";
// API routes // API routes
import apiRouter from "./routes/api/api.route.js"; import apiRouter from "./routes/api/api.route.js";
dotenv.config();
const app = express();
const port = 8004;
const naasURL = process.env.NAAS_URL;
app.use(cors()); app.use(cors());
// Body-Parser VOR den Routen registrieren // Body-Parser VOR den Routen registrieren
app.use(express.json({ limit: "10mb" })); app.use(express.json({ limit: "10mb" }));
@@ -38,6 +51,7 @@ app.use("/api/admin/user-data", userDataMgmtRouter);
app.use("/api/admin/item-data", itemDataMgmtRouter); app.use("/api/admin/item-data", itemDataMgmtRouter);
app.use("/api/admin/api-data", apiDataMgmtRouter); app.use("/api/admin/api-data", apiDataMgmtRouter);
app.use("/api/admin/user-mgmt", userMgmtRouterADMIN); app.use("/api/admin/user-mgmt", userMgmtRouterADMIN);
app.use("/api/admin/server-config", serverConfMgmtRouter);
// API routes // API routes
app.use("/api", apiRouter); app.use("/api", apiRouter);
+18
View File
@@ -1,8 +1,12 @@
import { SignJWT, jwtVerify } from "jose"; import { SignJWT, jwtVerify } from "jose";
import env from "dotenv"; import env from "dotenv";
import { verifyAPIKeyDB } from "./database.js"; import { verifyAPIKeyDB } from "./database.js";
import { checkIfServiceIsActive2 } from "./functions.js";
env.config(); env.config();
const api_service = "API";
const user_frontend_service = "User Frontend";
const secretKey = process.env.SECRET_KEY; const secretKey = process.env.SECRET_KEY;
if (!secretKey) { if (!secretKey) {
throw new Error("Missing SECRET_KEY environment variable"); throw new Error("Missing SECRET_KEY environment variable");
@@ -45,6 +49,13 @@ export async function authenticate(req, res, next) {
const apiKey = req.params.key; const apiKey = req.params.key;
if (authHeader) { 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 parts = authHeader.split(" ");
const scheme = parts[0]; const scheme = parts[0];
const token = parts[1]; 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 return res.status(403).json({ message: "Present token invalid" }); // present token invalid
} }
} else if (apiKey) { } else if (apiKey) {
const serviceActive = await checkIfServiceIsActive2(api_service);
if (!serviceActive) {
return res
.status(503)
.json({ message: "API Service is currently unavailable." });
}
try { try {
await verifyAPIKey(apiKey); await verifyAPIKey(apiKey);
return next(); 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,
},
});
+8 -7
View File
@@ -4,16 +4,17 @@ This update provides some new features for the design. It also contains some imp
## New features ## New features
- The overview page now has the note column and is overall better organised - **Deactivatable services:** I have added the ability to deactivate services, which can be useful for maintenance or other purposes. The admin can activate and deactivate services in the admin panel. If a service is deactivated, it will not be available for users and they will get an error message if they try to use it. They will also get an warning banner on the homepage.
- I also addded the regular header to the page - **New Animations:** I have added some new animations to the frontend, which make the user experience more enjoyable.
- I have added three animations to the Borrow System - **New Icon:** I have added a new icon for the frontend, which is now also used in the header and the favicon. It is a dark version of the old icon, which fits better to the overall design. I have made it with Icon Composer. The old icon is still used for the admin panel, which has a light design. (Maybe I will change the admin panel design in the future...)
- I have added a new icon for the frontend, which is now also used in the header and the favicon. It is a dark version of the old icon, which fits better to the overall design. I have made it with Icon Composer. The old icon is still used for the admin panel, which has a light design. (Maybe I will change the admin panel design in the future...) - **New Button:** When you go to your user card (over the user icon in the header) you have a new button "Click me". If you click it, you will get an message... _I am just saying: I have implemented the no-as-a-service code in to my Backend._
- When you go to your user card (over the user icon in the header) you have a new button "Click me". If you click it, you will get an message... _I am just saying: I have implemented the no-as-a-service code in to my Backend._
## Improvements ## Improvements
- I have the error logging for the API route wehre you can take loans improved. - The overview page now shows the note column and is overall better organised.
- Improved error logging
- If you try to delete a loan that has not been returned yet, you will get an 507 error code. - If you try to delete a loan that has not been returned yet, you will get an 507 error code.
- When the admin deletes a loan, the loan will be still visible in the database, but it will be marked as deleted. This is to prevent data loss and to keep track of deleted loans.
## Fixed bugs ## Fixed bugs
@@ -29,7 +30,7 @@ This update provides some new features for the design. It also contains some imp
**Frontend:** v2.2 **Frontend:** v2.2
**Admin panel:** v1.3.2 **Admin panel:** v1.4
--- ---