diff --git a/.env b/.env
index bfc766f..6e42e99 100644
--- a/.env
+++ b/.env
@@ -1 +1,7 @@
-VUE_APP_BACKEND_URL=http://localhost:3000/api
+SMTP_HOST=smtp.1blu.de
+SMTP_PORT=465
+SMTP_USER=e226079_0-kontakt
+SMTP_PASS=hitomisan
+SMTP_FROM=kontakt@tsschulz.de
+FRONTEND_URL=http://localhost:8080
+VUE_APP_BACKEND_URL=http://localhost:3002/api
diff --git a/config/email.js b/config/email.js
new file mode 100644
index 0000000..1feb88e
--- /dev/null
+++ b/config/email.js
@@ -0,0 +1,79 @@
+const nodemailer = require('nodemailer');
+
+// E-Mail-Konfiguration
+const smtpConfig = {
+ host: process.env.SMTP_HOST || 'smtp.1blu.de',
+ port: process.env.SMTP_PORT || 465,
+ secure: true, // true für 465, false für andere Ports
+ auth: {
+ user: process.env.SMTP_USER || 'e226079_0-kontakt',
+ pass: process.env.SMTP_PASS || 'aNN31bll3Na!'
+ }
+};
+
+// Debug-Logging der SMTP-Konfiguration
+console.log('=== SMTP CONFIGURATION DEBUG ===');
+console.log('Host:', smtpConfig.host);
+console.log('Port:', smtpConfig.port);
+console.log('Secure:', smtpConfig.secure);
+console.log('User:', smtpConfig.auth.user);
+console.log('Pass:', smtpConfig.auth.pass.replace(/./g, '*')); // Passwort maskieren
+console.log('Environment Variables:');
+console.log(' SMTP_HOST:', process.env.SMTP_HOST || 'undefined');
+console.log(' SMTP_PORT:', process.env.SMTP_PORT || 'undefined');
+console.log(' SMTP_USER:', process.env.SMTP_USER || 'undefined');
+console.log(' SMTP_PASS:', process.env.SMTP_PASS ? '***' : 'undefined');
+console.log('================================');
+
+const transporter = nodemailer.createTransport(smtpConfig);
+
+// E-Mail-Template für Passwort-Reset
+const getPasswordResetEmailTemplate = (resetUrl, userName) => {
+ return {
+ subject: 'Passwort zurücksetzen - Miriam Gemeinde',
+ html: `
+
+
Passwort zurücksetzen
+
Hallo ${userName},
+
Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.
+
Klicken Sie auf den folgenden Link, um ein neues Passwort zu erstellen:
+
+
+ Passwort zurücksetzen
+
+
+
Dieser Link ist 1 Stunde gültig.
+
Falls Sie diese Anfrage nicht gestellt haben, können Sie diese E-Mail ignorieren.
+
+
+ Miriam Gemeinde
+ Diese E-Mail wurde automatisch generiert.
+
+
+ `,
+ text: `
+ Passwort zurücksetzen - Miriam Gemeinde
+
+ Hallo ${userName},
+
+ Sie haben eine Anfrage zum Zurücksetzen Ihres Passworts gestellt.
+
+ Klicken Sie auf den folgenden Link, um ein neues Passwort zu erstellen:
+ ${resetUrl}
+
+ Dieser Link ist 1 Stunde gültig.
+
+ Falls Sie diese Anfrage nicht gestellt haben, können Sie diese E-Mail ignorieren.
+
+ ---
+ Miriam Gemeinde
+ Diese E-Mail wurde automatisch generiert.
+ `
+ };
+};
+
+module.exports = {
+ transporter,
+ getPasswordResetEmailTemplate
+};
diff --git a/controllers/authController.js b/controllers/authController.js
index 8ab52b8..333857c 100644
--- a/controllers/authController.js
+++ b/controllers/authController.js
@@ -1,7 +1,9 @@
const bcrypt = require('bcryptjs');
-const { User } = require('../models');
+const { User, PasswordResetToken } = require('../models');
const jwt = require('jsonwebtoken');
const { addTokenToBlacklist } = require('../utils/blacklist');
+const { transporter, getPasswordResetEmailTemplate } = require('../config/email');
+const crypto = require('crypto');
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
@@ -87,6 +89,106 @@ exports.login = async (req, res) => {
}
};
+exports.forgotPassword = async (req, res) => {
+ const { email } = req.body;
+ if (!email) {
+ return res.status(400).json({ message: 'E-Mail-Adresse ist erforderlich' });
+ }
+ try {
+ const user = await User.findOne({ where: { email } });
+ if (!user) {
+ // Aus Sicherheitsgründen immer Erfolg melden, auch wenn E-Mail nicht existiert
+ return res.status(200).json({ message: 'Falls die E-Mail-Adresse in unserem System registriert ist, erhalten Sie einen Link zum Zurücksetzen des Passworts.' });
+ }
+
+ // Alte Reset-Tokens für diesen User löschen
+ await PasswordResetToken.destroy({ where: { userId: user.id } });
+
+ // Neuen Reset-Token generieren
+ const token = crypto.randomBytes(32).toString('hex');
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 Stunde
+
+ await PasswordResetToken.create({
+ userId: user.id,
+ token,
+ expiresAt
+ });
+
+ // Reset-URL generieren
+ const resetUrl = `${process.env.FRONTEND_URL || 'http://localhost:8080'}/reset-password?token=${token}`;
+
+ // E-Mail versenden
+ const emailTemplate = getPasswordResetEmailTemplate(resetUrl, user.name);
+
+ const mailOptions = {
+ from: process.env.SMTP_FROM || 'noreply@miriamgemeinde.de',
+ to: email,
+ subject: emailTemplate.subject,
+ html: emailTemplate.html,
+ text: emailTemplate.text
+ };
+
+ console.log('=== EMAIL SENDING DEBUG ===');
+ console.log('From:', mailOptions.from);
+ console.log('To:', mailOptions.to);
+ console.log('Subject:', mailOptions.subject);
+ console.log('Reset URL:', resetUrl);
+ console.log('===========================');
+
+ await transporter.sendMail(mailOptions);
+
+ console.log('Password reset email sent to:', email);
+ return res.status(200).json({ message: 'Falls die E-Mail-Adresse in unserem System registriert ist, erhalten Sie einen Link zum Zurücksetzen des Passworts.' });
+ } catch (error) {
+ console.error('Forgot password error:', error);
+ return res.status(500).json({ message: 'Ein Fehler ist aufgetreten' });
+ }
+};
+
+exports.resetPassword = async (req, res) => {
+ const { token, password } = req.body;
+ if (!token || !password) {
+ return res.status(400).json({ message: 'Token und neues Passwort sind erforderlich' });
+ }
+ if (password.length < 6) {
+ return res.status(400).json({ message: 'Passwort muss mindestens 6 Zeichen lang sein' });
+ }
+
+ try {
+ // Token validieren
+ const resetToken = await PasswordResetToken.findOne({
+ where: {
+ token,
+ used: false,
+ expiresAt: {
+ [require('sequelize').Op.gt]: new Date()
+ }
+ },
+ include: [{ model: User, as: 'user' }]
+ });
+
+ if (!resetToken) {
+ return res.status(400).json({ message: 'Ungültiger oder abgelaufener Token' });
+ }
+
+ // Passwort hashen und aktualisieren
+ const hashedPassword = await bcrypt.hash(password, 10);
+ await User.update(
+ { password: hashedPassword },
+ { where: { id: resetToken.userId } }
+ );
+
+ // Token als verwendet markieren
+ await resetToken.update({ used: true });
+
+ console.log('Password reset successful for user:', resetToken.userId);
+ return res.status(200).json({ message: 'Passwort erfolgreich zurückgesetzt' });
+ } catch (error) {
+ console.error('Reset password error:', error);
+ return res.status(500).json({ message: 'Ein Fehler ist aufgetreten' });
+ }
+};
+
exports.logout = async (req, res) => {
const authHeader = req.header('Authorization');
if (!authHeader) {
diff --git a/migrations/20250924062315-create-password-reset-tokens.js b/migrations/20250924062315-create-password-reset-tokens.js
new file mode 100644
index 0000000..846ad90
--- /dev/null
+++ b/migrations/20250924062315-create-password-reset-tokens.js
@@ -0,0 +1,49 @@
+'use strict';
+
+/** @type {import('sequelize-cli').Migration} */
+module.exports = {
+ async up (queryInterface, Sequelize) {
+ await queryInterface.createTable('PasswordResetTokens', {
+ id: {
+ type: Sequelize.UUID,
+ defaultValue: Sequelize.UUIDV4,
+ primaryKey: true
+ },
+ userId: {
+ type: Sequelize.INTEGER,
+ allowNull: false,
+ references: {
+ model: 'Users',
+ key: 'id'
+ },
+ onUpdate: 'CASCADE',
+ onDelete: 'CASCADE'
+ },
+ token: {
+ type: Sequelize.STRING,
+ allowNull: false,
+ unique: true
+ },
+ expiresAt: {
+ type: Sequelize.DATE,
+ allowNull: false
+ },
+ used: {
+ type: Sequelize.BOOLEAN,
+ defaultValue: false
+ },
+ created_at: {
+ type: Sequelize.DATE,
+ allowNull: false
+ },
+ updated_at: {
+ type: Sequelize.DATE,
+ allowNull: false
+ }
+ });
+ },
+
+ async down (queryInterface, Sequelize) {
+ await queryInterface.dropTable('PasswordResetTokens');
+ }
+};
diff --git a/models/PasswordResetToken.js b/models/PasswordResetToken.js
new file mode 100644
index 0000000..0784c09
--- /dev/null
+++ b/models/PasswordResetToken.js
@@ -0,0 +1,45 @@
+const { DataTypes } = require('sequelize');
+
+module.exports = (sequelize) => {
+ const PasswordResetToken = sequelize.define('PasswordResetToken', {
+ id: {
+ type: DataTypes.UUID,
+ defaultValue: DataTypes.UUIDV4,
+ primaryKey: true
+ },
+ userId: {
+ type: DataTypes.INTEGER,
+ allowNull: false,
+ references: {
+ model: 'Users',
+ key: 'id'
+ }
+ },
+ token: {
+ type: DataTypes.STRING,
+ allowNull: false,
+ unique: true
+ },
+ expiresAt: {
+ type: DataTypes.DATE,
+ allowNull: false
+ },
+ used: {
+ type: DataTypes.BOOLEAN,
+ defaultValue: false
+ }
+ }, {
+ timestamps: true,
+ createdAt: 'created_at',
+ updatedAt: 'updated_at'
+ });
+
+ PasswordResetToken.associate = (models) => {
+ PasswordResetToken.belongsTo(models.User, {
+ foreignKey: 'userId',
+ as: 'user'
+ });
+ };
+
+ return PasswordResetToken;
+};
diff --git a/models/User.js b/models/User.js
index 19319cf..f87300d 100644
--- a/models/User.js
+++ b/models/User.js
@@ -25,5 +25,12 @@ module.exports = (sequelize) => {
updatedAt: false
});
+ User.associate = (models) => {
+ User.hasMany(models.PasswordResetToken, {
+ foreignKey: 'userId',
+ as: 'passwordResetTokens'
+ });
+ };
+
return User;
};
diff --git a/package-lock.json b/package-lock.json
index 6cdbd73..71e5f9f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "miriamgemeinde",
- "version": "0.1.0",
+ "version": "1.1.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "miriamgemeinde",
- "version": "0.1.0",
+ "version": "1.1.0",
"dependencies": {
"@iconoir/vue": "^7.7.0",
"@tiptap/extension-bold": "^2.4.0",
@@ -40,6 +40,7 @@
"moment": "^2.30.1",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.10.1",
+ "nodemailer": "^7.0.6",
"nodemon": "^3.1.3",
"sequelize": "^6.37.3",
"sequelize-cli": "^6.6.2",
@@ -12613,6 +12614,15 @@
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
"integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw=="
},
+ "node_modules/nodemailer": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz",
+ "integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/nodemon": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.4.tgz",
@@ -27916,6 +27926,11 @@
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz",
"integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw=="
},
+ "nodemailer": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.6.tgz",
+ "integrity": "sha512-F44uVzgwo49xboqbFgBGkRaiMgtoBrBEWCVincJPK9+S9Adkzt/wXCLKbf7dxucmxfTI5gHGB+bEmdyzN6QKjw=="
+ },
"nodemon": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.4.tgz",
diff --git a/package.json b/package.json
index 959fbd5..c1d14e8 100644
--- a/package.json
+++ b/package.json
@@ -4,8 +4,8 @@
"private": true,
"scripts": {
"serve": "vue-cli-service serve",
- "build": "vue-cli-service build && npm run copy-dist",
- "copy-dist": "cp -r dist/* public/",
+ "build": "vue-cli-service build && npm run copy-dist",
+ "copy-dist": "cp -r dist/* public/",
"lint": "vue-cli-service lint"
},
"dependencies": {
@@ -41,6 +41,7 @@
"moment": "^2.30.1",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.10.1",
+ "nodemailer": "^7.0.6",
"nodemon": "^3.1.3",
"sequelize": "^6.37.3",
"sequelize-cli": "^6.6.2",
diff --git a/routes/auth.js b/routes/auth.js
index fc21f30..7f184bb 100644
--- a/routes/auth.js
+++ b/routes/auth.js
@@ -5,6 +5,8 @@ const authMiddleware = require('../middleware/authMiddleware');
router.post('/register', authController.register);
router.post('/login', authController.login);
+router.post('/forgot-password', authController.forgotPassword);
+router.post('/reset-password', authController.resetPassword);
router.post('/logout', authMiddleware, authController.logout);
module.exports = router;
diff --git a/server.js b/server.js
index 151dcd6..55f105a 100644
--- a/server.js
+++ b/server.js
@@ -19,7 +19,7 @@ const imageRouter = require('./routes/image');
const filesRouter = require('./routes/files');
const app = express();
-const PORT = 3000;
+const PORT = 3002;
app.use(cors());
app.use(bodyParser.json());
diff --git a/src/axios.js b/src/axios.js
index d573ad2..ceee5df 100644
--- a/src/axios.js
+++ b/src/axios.js
@@ -25,7 +25,7 @@ axios.interceptors.response.use(
error => {
if (error.response && error.response.status === 401) {
store.dispatch('logout');
- router.push('/');
+ router.push('/auth/login');
}
return Promise.reject(error);
}
diff --git a/src/content/authentication/ForgotPasswordContent.vue b/src/content/authentication/ForgotPasswordContent.vue
index 1a22958..06355ce 100644
--- a/src/content/authentication/ForgotPasswordContent.vue
+++ b/src/content/authentication/ForgotPasswordContent.vue
@@ -1,9 +1,9 @@
Passwort vergessen
-
@@ -13,12 +13,52 @@
Registrieren
+
+
+
+
{{ dialogTitle }}
+
{{ dialogMessage }}
+
+
+
@@ -37,4 +77,22 @@
button {
margin-top: 20px;
}
+ .dialog {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: rgba(0, 0, 0, 0.4);
+ }
+ .dialog-content {
+ background: #fff;
+ padding: 16px;
+ border-radius: 4px;
+ max-width: 420px;
+ width: 90%;
+ }
diff --git a/src/content/authentication/ResetPasswordContent.vue b/src/content/authentication/ResetPasswordContent.vue
new file mode 100644
index 0000000..7b6b689
--- /dev/null
+++ b/src/content/authentication/ResetPasswordContent.vue
@@ -0,0 +1,145 @@
+
+
+
Neues Passwort setzen
+
+
+ Zurück zum Login
+
+
+
+
+
{{ dialogTitle }}
+
{{ dialogMessage }}
+
+
+
+
+
+
+
+
+
diff --git a/src/router.js b/src/router.js
index 267033c..6edaea7 100644
--- a/src/router.js
+++ b/src/router.js
@@ -52,6 +52,8 @@ router.beforeEach(async (to, from, next) => {
routes.forEach(route => router.addRoute(route));
addEditPagesRoute();
addRegisterRoute();
+ addForgotPasswordRoute();
+ addResetPasswordRoute();
router.addRoute({
path: '/:pathMatch(.*)*',
components: {
@@ -98,7 +100,37 @@ function addRegisterRoute() {
});
}
+function addForgotPasswordRoute() {
+ if (router.hasRoute('/forgot-password')) {
+ router.removeRoute('/forgot-password');
+ }
+ router.addRoute({
+ path: '/forgot-password',
+ components: {
+ default: () => import('./content/authentication/ForgotPasswordContent.vue'),
+ rightColumn: loadComponent('ImageContent')
+ },
+ name: 'forgot-password'
+ });
+}
+
+function addResetPasswordRoute() {
+ if (router.hasRoute('/reset-password')) {
+ router.removeRoute('/reset-password');
+ }
+ router.addRoute({
+ path: '/reset-password',
+ components: {
+ default: () => import('./content/authentication/ResetPasswordContent.vue'),
+ rightColumn: loadComponent('ImageContent')
+ },
+ name: 'reset-password'
+ });
+}
+
addEditPagesRoute();
addRegisterRoute();
+addForgotPasswordRoute();
+addResetPasswordRoute();
export default router;
diff --git a/src/store/index.js b/src/store/index.js
index 275def9..6c8b250 100644
--- a/src/store/index.js
+++ b/src/store/index.js
@@ -35,7 +35,7 @@ export default createStore({
localStorage.removeItem('isLoggedIn');
localStorage.removeItem('user');
localStorage.removeItem('token');
- router.push('/');
+ router.push('/auth/login');
},
setMenuData(state, menuData) {
state.menuData = menuData;