Compare commits
6 Commits
7c3df683ba
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| e16e62935d | |||
| 6df1cfef6b | |||
| a25e41d820 | |||
| 1e9b2c702b | |||
| 2d1e1ea88d | |||
| 0fd0cda405 |
21
package-lock.json
generated
21
package-lock.json
generated
@@ -14,6 +14,7 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"next": "15.3.2",
|
||||
"next-auth": "^4.24.11",
|
||||
"nodemailer": "^6.10.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
@@ -21,6 +22,7 @@
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
@@ -1599,6 +1601,16 @@
|
||||
"undici-types": "~6.19.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "6.4.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz",
|
||||
"integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.4.tgz",
|
||||
@@ -5023,6 +5035,15 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.10.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
||||
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/oauth": {
|
||||
"version": "0.9.15",
|
||||
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"next": "15.3.2",
|
||||
"next-auth": "^4.24.11",
|
||||
"nodemailer": "^6.10.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
@@ -22,6 +23,7 @@
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
|
||||
@@ -12,24 +12,24 @@ datasource db {
|
||||
model Account {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
type String
|
||||
provider String
|
||||
providerAccountId String
|
||||
type String // z.B. "oauth", "oidc"
|
||||
provider String // z.B. "keycloak", "credentials"
|
||||
providerAccountId String // Die ID des Benutzers beim Provider (z.B. Keycloak 'sub')
|
||||
refresh_token String? @db.Text
|
||||
access_token String? @db.Text
|
||||
expires_at Int? // Üblicherweise für access_token Ablauf (in Sekunden seit Unix-Epoche)
|
||||
token_type String?
|
||||
expires_at Int? // Unix-Timestamp (in Sekunden) für den Ablauf des access_token
|
||||
token_type String? // z.B. "Bearer"
|
||||
scope String?
|
||||
id_token String? @db.Text
|
||||
session_state String?
|
||||
|
||||
refresh_expires_in Int? // NEU: Lebensdauer des Refresh Tokens in Sekunden
|
||||
not_before_policy Int? // NEU: Keycloak "Not Before Policy" Wert (Unix-Timestamp)
|
||||
refresh_expires_in Int? // Lebensdauer des Refresh Tokens in Sekunden
|
||||
not_before_policy Int? // NEU: Keycloak "Not Before Policy" Wert (oft ein Timestamp)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([provider, providerAccountId])
|
||||
@@index([userId]) // Index für userId hinzugefügt für bessere Abfrageleistung
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model Session {
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useSession, signOut } from 'next-auth/react';
|
||||
import { useRouter } from 'next/router';
|
||||
import { useEffect, useState, Fragment } from 'react';
|
||||
import { Dialog, Transition } from '@headlessui/react';
|
||||
// import { useTheme } from '../../context/ThemeContext'; // Entfernt
|
||||
|
||||
// Typen für die Admin-Seite (Frontend)
|
||||
interface AdminUser {
|
||||
@@ -58,6 +57,13 @@ interface SetApprovalStatusApiResponse {
|
||||
approvedAt: Date | null;
|
||||
}
|
||||
|
||||
interface SendInvoicesApiResponse {
|
||||
message: string;
|
||||
sentToCount: number;
|
||||
errors: { userId: string; error: string }[];
|
||||
}
|
||||
|
||||
|
||||
const AdminPanelPage: NextPage = () => {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
@@ -79,6 +85,17 @@ const AdminPanelPage: NextPage = () => {
|
||||
const [isLoadingUserTransactions, setIsLoadingUserTransactions] = useState(false);
|
||||
const [isTransactionsModalOpen, setIsTransactionsModalOpen] = useState(false);
|
||||
|
||||
const [isInvoiceModalOpen, setIsInvoiceModalOpen] = useState(false);
|
||||
const [invoiceDeadline, setInvoiceDeadline] = useState<string>(() => {
|
||||
const today = new Date();
|
||||
today.setDate(today.getDate() + 14);
|
||||
return today.toISOString().split('T')[0];
|
||||
});
|
||||
// paypalEmail und cashPaymentContacts States entfernt, da sie jetzt fest im Backend sind
|
||||
const [isSendingInvoices, setIsSendingInvoices] = useState(false);
|
||||
const [selectedUserIdsForInvoice, setSelectedUserIdsForInvoice] = useState<string[]>([]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'loading') return;
|
||||
|
||||
@@ -249,6 +266,60 @@ const AdminPanelPage: NextPage = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openInvoiceModal = (userIds: string[] = []) => {
|
||||
setSelectedUserIdsForInvoice(userIds);
|
||||
setIsInvoiceModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSendInvoices = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
setIsSendingInvoices(true);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
|
||||
const payload: { deadline: string; userIds?: string[] } = { // paypalEmail und cashPaymentContacts entfernt
|
||||
deadline: invoiceDeadline,
|
||||
};
|
||||
|
||||
if (selectedUserIdsForInvoice.length > 0) {
|
||||
payload.userIds = selectedUserIdsForInvoice;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/admin/invoices/send-bulk', { // API-Route bleibt gleich
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data: SendInvoicesApiResponse = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || 'Fehler beim Senden der Rechnungs-E-Mails.');
|
||||
}
|
||||
setSuccessMessage(`${data.message} Fehler beim Senden an ${data.errors.length} Benutzer (Details in Konsole).`);
|
||||
if (data.errors.length > 0) {
|
||||
console.error("Fehler beim E-Mail-Versand für folgende Benutzer:", data.errors);
|
||||
}
|
||||
setIsInvoiceModalOpen(false);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
setError(err.message);
|
||||
} else {
|
||||
setError('Ein unbekannter Fehler beim Senden der Rechnungs-E-Mails ist aufgetreten.');
|
||||
}
|
||||
console.error("Fehler Rechnungsversand:", err);
|
||||
} finally {
|
||||
setIsSendingInvoices(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUserSelectionForInvoice = (userId: string, isSelected: boolean) => {
|
||||
if (isSelected) {
|
||||
setSelectedUserIdsForInvoice(prev => [...prev, userId]);
|
||||
} else {
|
||||
setSelectedUserIdsForInvoice(prev => prev.filter(id => id !== userId));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (status === 'loading' || (status === 'authenticated' && session?.user?.role !== 'admin' && !isLoadingUsers) ) {
|
||||
return (
|
||||
@@ -268,13 +339,12 @@ const AdminPanelPage: NextPage = () => {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-100 dark:bg-slate-900 p-4 sm:p-6 lg:p-8 transition-colors duration-300">
|
||||
{/* Header angepasst für mobile Ansicht */}
|
||||
<header className="mb-8 flex flex-col sm:flex-row sm:justify-between sm:items-center">
|
||||
<div className="mb-4 sm:mb-0"> {/* Div für Admin Panel Text */}
|
||||
<div className="mb-4 sm:mb-0">
|
||||
<h1 className="text-3xl font-bold text-gray-800 dark:text-gray-100">Admin Panel</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">Benutzerverwaltung & Übersicht</p>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-4"> {/* Container für Buttons */}
|
||||
<div className="flex flex-col space-y-2 sm:flex-row sm:space-y-0 sm:space-x-4">
|
||||
<button
|
||||
onClick={() => router.push('/dashboard')}
|
||||
className="w-full sm:w-auto px-4 py-2 bg-blue-500 text-white rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
@@ -293,7 +363,6 @@ const AdminPanelPage: NextPage = () => {
|
||||
{error && <div className="mb-4 p-3 bg-red-100 text-red-700 border border-red-300 rounded-md dark:bg-red-900 dark:text-red-200 dark:border-red-700">{error}</div>}
|
||||
{successMessage && <div className="mb-4 p-3 bg-green-100 text-green-700 border border-green-300 rounded-md dark:bg-green-900 dark:text-green-200 dark:border-green-700">{successMessage}</div>}
|
||||
|
||||
{/* Gesamtübersicht Sektion */}
|
||||
<section className="mb-8 p-6 bg-white dark:bg-slate-800 shadow-lg rounded-lg">
|
||||
<h2 className="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-4">Gesamtübersicht</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
@@ -314,7 +383,31 @@ const AdminPanelPage: NextPage = () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Benutzerliste */}
|
||||
<section className="mb-8 p-6 bg-white dark:bg-slate-800 shadow-lg rounded-lg">
|
||||
<h2 className="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-4">Rechnungsversand</h2>
|
||||
<div className="space-y-3 sm:space-y-0 sm:flex sm:space-x-3">
|
||||
<button
|
||||
onClick={() => openInvoiceModal()}
|
||||
className="w-full sm:w-auto px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-orange-600 hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500 dark:bg-orange-500 dark:hover:bg-orange-600"
|
||||
>
|
||||
Rechnung an alle (positiver Saldo)
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedUserIdsForInvoice.length === 0) {
|
||||
setError("Bitte wähle zuerst Benutzer aus der Liste unten aus.");
|
||||
return;
|
||||
}
|
||||
openInvoiceModal(selectedUserIdsForInvoice);
|
||||
}}
|
||||
disabled={selectedUserIdsForInvoice.length === 0}
|
||||
className="w-full sm:w-auto px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-cyan-600 hover:bg-cyan-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 disabled:opacity-50 dark:bg-cyan-500 dark:hover:bg-cyan-600"
|
||||
>
|
||||
Rechnung an Ausgewählte ({selectedUserIdsForInvoice.length})
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="p-6 bg-white dark:bg-slate-800 shadow-lg rounded-lg">
|
||||
<h2 className="text-2xl font-semibold text-gray-700 dark:text-gray-200 mb-4">Benutzerübersicht</h2>
|
||||
{isLoadingUsers ? (
|
||||
@@ -324,6 +417,9 @@ const AdminPanelPage: NextPage = () => {
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-slate-700">
|
||||
<tr>
|
||||
<th className="px-1 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
<span className="sr-only">Alle auswählen</span>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Name</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">E-Mail</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Saldo</th>
|
||||
@@ -334,7 +430,17 @@ const AdminPanelPage: NextPage = () => {
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="dark:hover:bg-slate-700/50">
|
||||
<tr key={user.id} className={`dark:hover:bg-slate-700/50 ${selectedUserIdsForInvoice.includes(user.id) ? 'bg-blue-50 dark:bg-blue-900/30' : ''}`}>
|
||||
<td className="px-1 py-4 whitespace-nowrap">
|
||||
{parseFloat(user.balance || "0") > 0 && (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 text-indigo-600 border-gray-300 dark:border-gray-600 rounded focus:ring-indigo-500 dark:bg-slate-700 dark:focus:ring-indigo-600"
|
||||
checked={selectedUserIdsForInvoice.includes(user.id)}
|
||||
onChange={(e) => handleUserSelectionForInvoice(user.id, e.target.checked)}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{user.name || 'N/A'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300">{user.email || 'N/A'}</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-700 dark:text-gray-300 font-medium">{user.balance ? `${user.balance} €` : '0.00 €'}</td>
|
||||
@@ -379,7 +485,74 @@ const AdminPanelPage: NextPage = () => {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Modal für Saldo anpassen */}
|
||||
{/* Modal für Rechnungsversand */}
|
||||
<Transition appear show={isInvoiceModalOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-20" onClose={() => setIsInvoiceModalOpen(false)}>
|
||||
<Transition.Child
|
||||
as="div" className="fixed inset-0"
|
||||
enter="ease-out duration-300" enterFrom="opacity-0" enterTo="opacity-100"
|
||||
leave="ease-in duration-200" leaveFrom="opacity-100" leaveTo="opacity-0"
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/30 backdrop-blur-sm dark:bg-slate-900/80" />
|
||||
</Transition.Child>
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as="div"
|
||||
className="w-full max-w-lg transform overflow-hidden rounded-2xl bg-white dark:bg-slate-800 p-6 text-left align-middle shadow-xl transition-all"
|
||||
enter="ease-out duration-300" enterFrom="opacity-0 scale-95" enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200" leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title as="h3" className="text-lg font-medium leading-6 text-gray-900 dark:text-gray-100">
|
||||
Rechnungs-E-Mails senden
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{selectedUserIdsForInvoice.length > 0
|
||||
? `E-Mail wird an ${selectedUserIdsForInvoice.length} ausgewählte Benutzer gesendet.`
|
||||
: "E-Mail wird an alle Benutzer mit positivem Saldo gesendet."
|
||||
}
|
||||
<br/>Die PayPal-Adresse und Barzahlungskontakte sind fest hinterlegt.
|
||||
</Dialog.Description>
|
||||
<form onSubmit={handleSendInvoices} className="mt-4 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="invoiceDeadline" className="block text-sm font-medium text-gray-700 dark:text-gray-300">Zahlungsfrist</label>
|
||||
<input
|
||||
type="date"
|
||||
id="invoiceDeadline"
|
||||
value={invoiceDeadline}
|
||||
onChange={(e) => setInvoiceDeadline(e.target.value)}
|
||||
required
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-400 dark:border-gray-600 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm text-gray-900 dark:text-gray-100 bg-white dark:bg-slate-700"
|
||||
/>
|
||||
</div>
|
||||
{/* Eingabefelder für PayPal E-Mail und Barzahlungskontakte entfernt */}
|
||||
<div className="mt-6 flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-md border border-transparent bg-gray-200 dark:bg-slate-700 px-4 py-2 text-sm font-medium text-gray-800 dark:text-gray-100 hover:bg-gray-300 dark:hover:bg-slate-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-500 focus-visible:ring-offset-2"
|
||||
onClick={() => setIsInvoiceModalOpen(false)}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSendingInvoices}
|
||||
className="inline-flex justify-center rounded-md border border-transparent bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-green-500 focus-visible:ring-offset-2 disabled:opacity-50 dark:bg-green-500 dark:hover:bg-green-600"
|
||||
>
|
||||
{isSendingInvoices ? 'Senden...' : 'E-Mails senden'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
|
||||
{/* Andere Modals (Saldo anpassen, Transaktionsverlauf) bleiben unverändert */}
|
||||
{/* ... Code für AdjustModal und TransactionsModal ... */}
|
||||
<Transition appear show={isAdjustModalOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={() => setIsAdjustModalOpen(false)}>
|
||||
<Transition.Child
|
||||
@@ -472,7 +645,6 @@ const AdminPanelPage: NextPage = () => {
|
||||
</Dialog>
|
||||
</Transition>
|
||||
|
||||
{/* Modal für Transaktionsverlauf */}
|
||||
<Transition appear show={isTransactionsModalOpen} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={() => setIsTransactionsModalOpen(false)}>
|
||||
<Transition.Child
|
||||
|
||||
241
src/pages/api/admin/invoices/send-bulk.ts
Normal file
241
src/pages/api/admin/invoices/send-bulk.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
// Datei: pages/api/admin/invoices/send-bulk.ts
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { getServerSession } from 'next-auth/next';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
import nodemailer from 'nodemailer';
|
||||
import {authOptions} from "@/pages/api/auth/[...nextauth]";
|
||||
import prisma from "@/lib/prisma"
|
||||
|
||||
// FESTE KONSTANTEN
|
||||
const FIXED_CASH_PAYMENT_CONTACTS = "Thomas, Elias oder Paul"; // <-- HIER DEINE KONTAKTE EINTRAGEN
|
||||
|
||||
interface SendBulkInvoicesRequestBody {
|
||||
deadline: string;
|
||||
// paypalEmail: string; // Entfernt
|
||||
// cashPaymentContacts: string; // Entfernt
|
||||
userIds?: string[];
|
||||
}
|
||||
|
||||
interface SuccessResponse {
|
||||
message: string;
|
||||
sentToCount: number;
|
||||
errors: { userId: string; error: string }[];
|
||||
}
|
||||
|
||||
interface ErrorResponse {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// HTML E-Mail Vorlage
|
||||
function createInvoiceHtml(
|
||||
userName: string | null,
|
||||
balance: Decimal,
|
||||
deadline: string // ISO String
|
||||
// paypalEmailText und cashPaymentContacts werden jetzt durch Konstanten ersetzt
|
||||
): string {
|
||||
const formattedDeadline = new Date(deadline).toLocaleDateString('de-DE', {
|
||||
year: 'numeric', month: 'long', day: 'numeric'
|
||||
});
|
||||
const formattedBalance = balance.toFixed(2);
|
||||
const paypalMeLink = `http://paypal.me/thomasqst/${formattedBalance}`;
|
||||
|
||||
return `
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Deine Strichlisten-Rechnung</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 20px; background-color: #f4f4f4; }
|
||||
.container { background-color: #fff; padding: 20px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); max-width: 600px; margin: auto; }
|
||||
h1 { color: #333; }
|
||||
p { margin-bottom: 10px; }
|
||||
.highlight { font-weight: bold; color: #d9534f; }
|
||||
.footer { margin-top: 20px; font-size: 0.9em; color: #777; text-align: center; }
|
||||
ul { list-style-type: none; padding: 0; }
|
||||
li { margin-bottom: 8px; }
|
||||
a { color: #007bff; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Hallo ${userName || 'Benutzer'},</h1>
|
||||
<p>dein aktueller Saldo auf der Strichliste beträgt: <span class="highlight">${formattedBalance} €</span>.</p>
|
||||
<p>Bitte begleiche diesen Betrag bis zum <strong>${formattedDeadline}</strong>.</p>
|
||||
<h2>Zahlungsmöglichkeiten:</h2>
|
||||
<ul>
|
||||
<li><strong>PayPal:</strong> <a href="${paypalMeLink}" target="_blank">${paypalMeLink}</a></li>
|
||||
<li><strong>Bar an:</strong> ${FIXED_CASH_PAYMENT_CONTACTS}</li>
|
||||
</ul>
|
||||
<p>Vielen Dank!</p>
|
||||
<p>Dein Strichlisten-Team</p>
|
||||
<div class="footer">
|
||||
<p>Dies ist eine automatisch generierte E-Mail.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
// Nodemailer Transporter erstellen
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT || "587"),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: true,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
async function sendInvoiceEmail(
|
||||
userName: string | null,
|
||||
userEmail: string | null,
|
||||
balance: Decimal,
|
||||
deadline: string
|
||||
// paypalEmailText und cashPaymentContacts entfernt, da Konstanten verwendet werden
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
if (!userEmail) {
|
||||
return { success: false, error: "Keine E-Mail-Adresse für den Benutzer vorhanden." };
|
||||
}
|
||||
if (!process.env.SMTP_HOST || !process.env.SMTP_USER || !process.env.SMTP_PASS || !process.env.SMTP_FROM_EMAIL) {
|
||||
console.error("SMTP-Konfiguration unvollständig. E-Mail-Versand übersprungen (simuliert).");
|
||||
const formattedDeadlineSim = new Date(deadline).toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
const formattedBalanceSim = balance.toFixed(2);
|
||||
const paypalMeLinkSim = `http://paypal.me/thomasqst/${formattedBalanceSim}`;
|
||||
console.log("----------------------------------------------------");
|
||||
console.log(`SIMULIERTER E-Mail-Versand an: ${userEmail}`);
|
||||
console.log(`Betreff: Deine Strichlisten-Rechnung - Saldo: ${formattedBalanceSim}€`);
|
||||
console.log(`Inhalt: Saldo ${formattedBalanceSim}€, Frist ${formattedDeadlineSim}, PayPal Link ${paypalMeLinkSim}, Bar bei ${FIXED_CASH_PAYMENT_CONTACTS}`);
|
||||
console.log("----------------------------------------------------");
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
const htmlContent = createInvoiceHtml(userName, balance, deadline); // Parameter entfernt
|
||||
const mailOptions = {
|
||||
from: `"${process.env.SMTP_FROM_NAME || 'Strichlisten Team'}" <${process.env.SMTP_FROM_EMAIL}>`,
|
||||
to: userEmail,
|
||||
subject: `Deine Strichlisten-Rechnung - Saldo: ${balance.toFixed(2)}€`,
|
||||
html: htmlContent,
|
||||
text: `Hallo ${userName || ''},\n\n` +
|
||||
`dein aktueller Saldo auf der Strichliste beträgt: ${balance.toFixed(2)} €.\n` +
|
||||
`Bitte begleiche diesen Betrag bis zum ${new Date(deadline).toLocaleDateString('de-DE', { year: 'numeric', month: 'long', day: 'numeric' })}.\n\n` +
|
||||
`Zahlungsmöglichkeiten:\n` +
|
||||
`- PayPal: http://paypal.me/thomasqst/${balance.toFixed(2)}\n` +
|
||||
`- Bar an: ${FIXED_CASH_PAYMENT_CONTACTS}\n\n` +
|
||||
`Vielen Dank!\n` +
|
||||
`Dein Strichlisten-Team`
|
||||
};
|
||||
|
||||
try {
|
||||
await transporter.sendMail(mailOptions);
|
||||
console.log(`Rechnungs-E-Mail erfolgreich gesendet an: ${userEmail}`);
|
||||
return { success: true };
|
||||
} catch (e: unknown) {
|
||||
console.error(`Fehler beim Senden der E-Mail an ${userEmail}:`, e);
|
||||
if (!(e instanceof Error)) return { success: false, error: 'Unbekannter Fehler beim E-Mail-Versand' };
|
||||
return { success: false, error: e.message || 'Unbekannter Fehler beim E-Mail-Versand' };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<SuccessResponse | ErrorResponse>
|
||||
) {
|
||||
if (req.method !== 'POST') {
|
||||
res.setHeader('Allow', ['POST']);
|
||||
return res.status(405).json({ message: `Method ${req.method} Not Allowed` });
|
||||
}
|
||||
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session || !session.user || session.user.role !== 'admin') {
|
||||
return res.status(403).json({ message: 'Zugriff verweigert. Nur für Administratoren.' });
|
||||
}
|
||||
|
||||
const { deadline, userIds } = req.body as SendBulkInvoicesRequestBody; // paypalEmail und cashPaymentContacts entfernt
|
||||
|
||||
if (!deadline) { // Prüfung auf cashPaymentContacts entfernt
|
||||
return res.status(400).json({ message: 'Frist ist erforderlich.' });
|
||||
}
|
||||
try {
|
||||
new Date(deadline);
|
||||
} catch (e) {
|
||||
console.error('Ungültiges Datumsformat für die Frist:', e);
|
||||
return res.status(400).json({ message: 'Ungültiges Datumsformat für die Frist.' });
|
||||
}
|
||||
|
||||
try {
|
||||
let usersToInvoice;
|
||||
if (userIds && userIds.length > 0) {
|
||||
usersToInvoice = await prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: userIds },
|
||||
balance: { gt: 0 },
|
||||
isApproved: true,
|
||||
},
|
||||
select: { id: true, name: true, email: true, balance: true },
|
||||
});
|
||||
} else {
|
||||
usersToInvoice = await prisma.user.findMany({
|
||||
where: {
|
||||
balance: { gt: 0 },
|
||||
isApproved: true,
|
||||
},
|
||||
select: { id: true, name: true, email: true, balance: true },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
if (usersToInvoice.length === 0) {
|
||||
return res.status(200).json({ message: 'Keine passenden Benutzer für den Rechnungsversand gefunden.', sentToCount: 0, errors: [] });
|
||||
}
|
||||
|
||||
let sentCount = 0;
|
||||
const emailErrors: { userId: string; error: string }[] = [];
|
||||
|
||||
for (const user of usersToInvoice) {
|
||||
if (user.balance) {
|
||||
const result = await sendInvoiceEmail(
|
||||
user.name,
|
||||
user.email,
|
||||
user.balance,
|
||||
deadline
|
||||
// Parameter für PayPal und Kontakte entfernt
|
||||
);
|
||||
if (result.success) {
|
||||
sentCount++;
|
||||
} else {
|
||||
emailErrors.push({ userId: user.id, error: result.error || 'Unbekannter Fehler beim E-Mail-Versand' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const targetAudience = userIds && userIds.length > 0 ? `${userIds.length} ausgewählte(n)` : "allen";
|
||||
const finalMessage = emailErrors.length === 0
|
||||
? `Rechnungs-E-Mails wurden erfolgreich an ${sentCount} von ${usersToInvoice.length} (${targetAudience}) Benutzer(n) gesendet.`
|
||||
: `Rechnungs-E-Mails wurden an ${sentCount} von ${usersToInvoice.length} (${targetAudience}) Benutzer(n) gesendet. ${emailErrors.length} Fehler sind aufgetreten.`;
|
||||
|
||||
|
||||
return res.status(200).json({
|
||||
message: finalMessage,
|
||||
sentToCount: sentCount,
|
||||
errors: emailErrors,
|
||||
});
|
||||
|
||||
} catch (error: unknown) {
|
||||
console.error('Fehler beim Senden der Rechnungs-E-Mails:', error);
|
||||
if (error instanceof Error) {
|
||||
return res.status(500).json({ message: `Interner Serverfehler: ${error.message}` });
|
||||
}
|
||||
return res.status(500).json({ message: 'Ein unbekannter interner Serverfehler ist aufgetreten.' });
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
// Datei: pages/api/auth/[...nextauth].ts
|
||||
|
||||
import NextAuth, {AuthOptions, TokenSet} from "next-auth";
|
||||
import CredentialsProvider from "next-auth/providers/credentials";
|
||||
import CredentialsProvider, {CredentialInput} from "next-auth/providers/credentials";
|
||||
import KeycloakProvider, { KeycloakProfile } from "next-auth/providers/keycloak";
|
||||
import { PrismaAdapter } from "@next-auth/prisma-adapter";
|
||||
import prisma from "../../../lib/prisma";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
import type { User } from "next-auth";
|
||||
import type { User, Account, Profile } from "next-auth";
|
||||
import {AdapterUser} from "next-auth/adapters"; // Account und Profile importieren für den signIn Callback
|
||||
|
||||
export const authOptions: AuthOptions = {
|
||||
adapter: PrismaAdapter(prisma),
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
// Debug-Modus für ausführlichere Logs von NextAuth.js aktivieren
|
||||
debug: true, // process.env.NODE_ENV === 'development',
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
providers: [
|
||||
KeycloakProvider({
|
||||
clientId: process.env.KEYCLOAK_CLIENT_ID as string,
|
||||
@@ -23,14 +23,11 @@ export const authOptions: AuthOptions = {
|
||||
issuer: process.env.KEYCLOAK_ISSUER as string,
|
||||
allowDangerousEmailAccountLinking: true,
|
||||
profile(profile: KeycloakProfile, tokens: TokenSet): User | Promise<User> {
|
||||
// Detailliertes Logging des empfangenen Keycloak-Profils und der Tokens
|
||||
console.log("[NextAuth.js] Keycloak Profile Received:", JSON.stringify(profile, null, 2));
|
||||
// Vorsicht beim Loggen von Tokens in der Produktion, hier nur ein Snippet des ID-Tokens
|
||||
if (tokens.id_token) {
|
||||
console.log("[NextAuth.js] Keycloak ID Token Snippet:", tokens.id_token.substring(0, 50) + "...");
|
||||
}
|
||||
|
||||
|
||||
let userRole = 'user';
|
||||
if (profile.realm_access?.roles && Array.isArray(profile.realm_access.roles) && profile.realm_access.roles.includes('admin')) {
|
||||
userRole = 'admin';
|
||||
@@ -38,22 +35,20 @@ export const authOptions: AuthOptions = {
|
||||
userRole = 'admin';
|
||||
}
|
||||
|
||||
const emailFromProvider = profile.email?.toLowerCase(); // E-Mail normalisieren (zu Kleinbuchstaben)
|
||||
const emailFromProvider = profile.email?.toLowerCase();
|
||||
|
||||
if (!emailFromProvider) {
|
||||
console.error("[NextAuth.js] Email not provided by Keycloak profile. Profile data:", profile);
|
||||
// Du könntest hier entscheiden, einen Fehler zu werfen oder einen Fallback zu implementieren,
|
||||
// aber für die Kontoverknüpfung ist eine E-Mail essentiell.
|
||||
throw new Error("E-Mail wurde nicht vom Identitätsprovider (Keycloak) bereitgestellt.");
|
||||
}
|
||||
|
||||
const userToReturn: User = {
|
||||
id: profile.sub,
|
||||
name: profile.name || profile.preferred_username,
|
||||
email: emailFromProvider, // Normalisierte E-Mail verwenden
|
||||
email: emailFromProvider,
|
||||
image: profile.picture,
|
||||
role: userRole,
|
||||
isApproved: true, // Annahme: OIDC-Benutzer sind standardmäßig freigegeben
|
||||
isApproved: true,
|
||||
};
|
||||
|
||||
console.log("[NextAuth.js] User object to be processed by adapter/callbacks:", JSON.stringify(userToReturn, null, 2));
|
||||
@@ -95,6 +90,27 @@ export const authOptions: AuthOptions = {
|
||||
}),
|
||||
],
|
||||
callbacks: {
|
||||
async signIn({account}: { user: User | AdapterUser, account: Account | null, profile?: Profile | KeycloakProfile, email?: { verificationRequest?: boolean }, credentials?: Record<string, CredentialInput> }) {
|
||||
if (account && account.provider === "keycloak") {
|
||||
// Das 'account'-Objekt hier enthält die rohen Daten vom Token-Endpunkt des Providers.
|
||||
// Wir verwenden eine Typ-Assertion, um TypeScript mitzuteilen, dass wir zusätzliche,
|
||||
// provider-spezifische Felder erwarten könnten.
|
||||
const keycloakAccount = account as Account & {
|
||||
'not-before-policy'?: unknown; // Geändert von any zu unknown
|
||||
not_before_policy?: unknown; // Geändert von any zu unknown
|
||||
// Füge hier weitere provider-spezifische Felder hinzu, falls nötig
|
||||
};
|
||||
|
||||
if (keycloakAccount['not-before-policy'] !== undefined) {
|
||||
console.log(`[NextAuth.js] signIn callback: Renaming 'not-before-policy' to 'not_before_policy' for account: ${account.providerAccountId}`);
|
||||
// Der Wert wird einfach kopiert. Die Typüberprüfung für den Wert selbst ist hier weniger kritisch
|
||||
// als die Vermeidung von 'any' in der Typdefinition.
|
||||
keycloakAccount.not_before_policy = keycloakAccount['not-before-policy'];
|
||||
delete keycloakAccount['not-before-policy'];
|
||||
}
|
||||
}
|
||||
return true; // Erlaube den Anmeldevorgang
|
||||
},
|
||||
async jwt({ token, user, account }) {
|
||||
if (account && user) {
|
||||
token.id = user.id;
|
||||
|
||||
Reference in New Issue
Block a user