From c7a0316ca03023381805bb1a6f5b80dcd5cbc927 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 17 Oct 2025 23:39:45 +0200 Subject: [PATCH] Add invite routes to backend and frontend; implement routing and UI components for invitation management --- backend/create-invitation-table.sql | 28 ++ backend/src/config/database.js | 8 +- backend/src/controllers/InviteController.js | 63 ++++ backend/src/index.js | 4 + backend/src/models/Invitation.js | 83 +++++ backend/src/models/index.js | 4 +- backend/src/routes/invite.js | 16 + backend/src/services/InviteService.js | 195 +++++++++++ frontend/src/App.vue | 1 + frontend/src/router/index.js | 7 + frontend/src/views/Invite.vue | 342 ++++++++++++++++++++ frontend/src/views/Workdays.vue | 3 +- 12 files changed, 751 insertions(+), 3 deletions(-) create mode 100644 backend/create-invitation-table.sql create mode 100644 backend/src/controllers/InviteController.js create mode 100644 backend/src/models/Invitation.js create mode 100644 backend/src/routes/invite.js create mode 100644 backend/src/services/InviteService.js create mode 100644 frontend/src/views/Invite.vue diff --git a/backend/create-invitation-table.sql b/backend/create-invitation-table.sql new file mode 100644 index 0000000..e0af3aa --- /dev/null +++ b/backend/create-invitation-table.sql @@ -0,0 +1,28 @@ +-- SQL Script: Invitation-Tabelle erstellen +-- Speichert gesendete Einladungen mit Spam-Schutz +-- Ausführen mit: mysql -u root -p stechuhr < create-invitation-table.sql + +USE stechuhr; + +-- Erstelle Invitation-Tabelle falls nicht vorhanden +CREATE TABLE IF NOT EXISTS invitation ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + inviter_user_id BIGINT NOT NULL, + email VARCHAR(255) NOT NULL, + token VARCHAR(255) NOT NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at DATETIME NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'pending', + + -- Foreign Keys + CONSTRAINT fk_invitation_user FOREIGN KEY (inviter_user_id) + REFERENCES user(id) ON DELETE CASCADE, + + -- Indices für Performance + INDEX idx_invitation_token (token), + INDEX idx_invitation_email_created (email, created_at), + INDEX idx_invitation_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +SELECT 'Invitation-Tabelle erfolgreich erstellt oder bereits vorhanden.' AS status; + diff --git a/backend/src/config/database.js b/backend/src/config/database.js index 42d934b..36bdafe 100644 --- a/backend/src/config/database.js +++ b/backend/src/config/database.js @@ -82,6 +82,7 @@ class Database { const SickType = require('../models/SickType'); const Timefix = require('../models/Timefix'); const Timewish = require('../models/Timewish'); + const Invitation = require('../models/Invitation'); // Models mit Sequelize-Instanz initialisieren User.initialize(this.sequelize); @@ -98,6 +99,7 @@ class Database { SickType.initialize(this.sequelize); Timefix.initialize(this.sequelize); Timewish.initialize(this.sequelize); + Invitation.initialize(this.sequelize); // Assoziationen definieren this.defineAssociations(); @@ -109,7 +111,7 @@ class Database { * Model-Assoziationen definieren */ defineAssociations() { - const { User, Worklog, AuthInfo, AuthToken, AuthIdentity, State, WeeklyWorktime, Holiday, HolidayState, Vacation, Sick, SickType, Timefix, Timewish } = this.sequelize.models; + const { User, Worklog, AuthInfo, AuthToken, AuthIdentity, State, WeeklyWorktime, Holiday, HolidayState, Vacation, Sick, SickType, Timefix, Timewish, Invitation } = this.sequelize.models; // User Assoziationen User.hasMany(Worklog, { foreignKey: 'user_id', as: 'worklogs' }); @@ -175,6 +177,10 @@ class Database { // Timewish Assoziationen Timewish.belongsTo(User, { foreignKey: 'user_id', as: 'user' }); User.hasMany(Timewish, { foreignKey: 'user_id', as: 'timewishes' }); + + // Invitation Assoziationen + Invitation.belongsTo(User, { foreignKey: 'inviter_user_id', as: 'inviter' }); + User.hasMany(Invitation, { foreignKey: 'inviter_user_id', as: 'invitations' }); } /** diff --git a/backend/src/controllers/InviteController.js b/backend/src/controllers/InviteController.js new file mode 100644 index 0000000..a67c9a1 --- /dev/null +++ b/backend/src/controllers/InviteController.js @@ -0,0 +1,63 @@ +const InviteService = require('../services/InviteService'); + +/** + * Controller für Einladungen + * Verarbeitet HTTP-Requests und delegiert an InviteService + */ +class InviteController { + /** + * Sendet eine Einladung + */ + async sendInvite(req, res) { + try { + const userId = req.user?.id || 1; + const { email } = req.body; + + if (!email) { + return res.status(400).json({ + message: 'Email-Adresse ist erforderlich' + }); + } + + const invitation = await InviteService.sendInvite(userId, email); + res.status(201).json(invitation); + } catch (error) { + console.error('Fehler beim Senden der Einladung:', error); + + // Spezifische Fehlermeldungen + if (error.message.includes('bereits eingeladen')) { + return res.status(429).json({ message: error.message }); // Too Many Requests + } + + if (error.message.includes('bereits registriert')) { + return res.status(409).json({ message: error.message }); // Conflict + } + + res.status(500).json({ + message: error.message || 'Fehler beim Senden der Einladung' + }); + } + } + + /** + * Holt alle Einladungen des aktuellen Users + */ + async getMyInvitations(req, res) { + try { + const userId = req.user?.id || 1; + const activeOnly = req.query.activeOnly === 'true'; + + const invitations = await InviteService.getUserInvitations(userId, activeOnly); + res.json(invitations); + } catch (error) { + console.error('Fehler beim Abrufen der Einladungen:', error); + res.status(500).json({ + message: 'Fehler beim Abrufen der Einladungen', + error: error.message + }); + } + } +} + +module.exports = new InviteController(); + diff --git a/backend/src/index.js b/backend/src/index.js index 63ab12b..b8bdad0 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -106,6 +106,10 @@ app.use('/api/timewish', authenticateToken, timewishRouter); const rolesRouter = require('./routes/roles'); app.use('/api/roles', authenticateToken, rolesRouter); +// Invite routes (geschützt) - MIT ID-Hashing +const inviteRouter = require('./routes/invite'); +app.use('/api/invite', authenticateToken, inviteRouter); + // Error handling middleware app.use((err, req, res, next) => { console.error(err.stack); diff --git a/backend/src/models/Invitation.js b/backend/src/models/Invitation.js new file mode 100644 index 0000000..5470639 --- /dev/null +++ b/backend/src/models/Invitation.js @@ -0,0 +1,83 @@ +const { Model, DataTypes } = require('sequelize'); + +/** + * Invitation Model + * Repräsentiert die invitation-Tabelle (falls vorhanden) + * Wenn nicht vorhanden, erstellen wir eine einfache In-Memory-Lösung + */ +class Invitation extends Model { + static initialize(sequelize) { + Invitation.init( + { + id: { + type: DataTypes.BIGINT, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + inviter_user_id: { + type: DataTypes.BIGINT, + allowNull: false, + references: { + model: 'user', + key: 'id' + } + }, + email: { + type: DataTypes.STRING(255), + allowNull: false + }, + token: { + type: DataTypes.STRING(255), + allowNull: false, + unique: true + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, + expires_at: { + type: DataTypes.DATE, + allowNull: false + }, + status: { + type: DataTypes.STRING(20), + allowNull: false, + defaultValue: 'pending' // pending, accepted, expired + } + }, + { + sequelize, + tableName: 'invitation', + timestamps: false, + indexes: [ + { + name: 'idx_invitation_token', + fields: ['token'], + unique: true + }, + { + name: 'idx_invitation_email_created', + fields: ['email', 'created_at'] + } + ] + } + ); + + return Invitation; + } + + /** + * Definiert Assoziationen mit anderen Models + */ + static associate(models) { + Invitation.belongsTo(models.User, { + foreignKey: 'inviter_user_id', + as: 'inviter' + }); + } +} + +module.exports = Invitation; + diff --git a/backend/src/models/index.js b/backend/src/models/index.js index 1bd28e0..a23e9fd 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -15,6 +15,7 @@ const Sick = require('./Sick'); const SickType = require('./SickType'); const Timefix = require('./Timefix'); const Timewish = require('./Timewish'); +const Invitation = require('./Invitation'); module.exports = { User, @@ -28,6 +29,7 @@ module.exports = { Sick, SickType, Timefix, - Timewish + Timewish, + Invitation }; diff --git a/backend/src/routes/invite.js b/backend/src/routes/invite.js new file mode 100644 index 0000000..1dc61ce --- /dev/null +++ b/backend/src/routes/invite.js @@ -0,0 +1,16 @@ +const express = require('express'); +const router = express.Router(); +const InviteController = require('../controllers/InviteController'); + +/** + * Routen für Einladungen + */ + +// GET /api/invite - Eigene Einladungen abrufen +router.get('/', InviteController.getMyInvitations.bind(InviteController)); + +// POST /api/invite - Neue Einladung senden +router.post('/', InviteController.sendInvite.bind(InviteController)); + +module.exports = router; + diff --git a/backend/src/services/InviteService.js b/backend/src/services/InviteService.js new file mode 100644 index 0000000..30c7d85 --- /dev/null +++ b/backend/src/services/InviteService.js @@ -0,0 +1,195 @@ +const database = require('../config/database'); +const { Op } = require('sequelize'); +const nodemailer = require('nodemailer'); + +/** + * Service-Klasse für Einladungen + * Verwaltet Einladungs-Emails mit Spam-Schutz + */ +class InviteService { + constructor() { + this.emailTransporter = null; + this.initializeEmailTransporter(); + } + + /** + * Initialisiert den Email-Transporter + */ + initializeEmailTransporter() { + if (!process.env.EMAIL_HOST) { + console.warn('⚠️ Email-Konfiguration fehlt - Einladungen können nicht versendet werden'); + return; + } + + this.emailTransporter = nodemailer.createTransport({ + host: process.env.EMAIL_HOST, + port: parseInt(process.env.EMAIL_PORT) || 587, + secure: process.env.EMAIL_SECURE === 'true', + auth: { + user: process.env.EMAIL_USER, + pass: process.env.EMAIL_PASSWORD + } + }); + } + + /** + * Sendet eine Einladungs-Email + * @param {number} inviterUserId - ID des einladenden Users + * @param {string} inviteeEmail - Email-Adresse des Eingeladenen + * @returns {Promise} Einladungs-Info + */ + async sendInvite(inviterUserId, inviteeEmail) { + const { User, Invitation } = database.getModels(); + + // Validiere Email + if (!inviteeEmail || !this._isValidEmail(inviteeEmail)) { + throw new Error('Ungültige Email-Adresse'); + } + + // Hole einladenden User + const inviter = await User.findByPk(inviterUserId); + if (!inviter) { + throw new Error('Benutzer nicht gefunden'); + } + + // Spam-Schutz: Prüfe ob diese Email heute schon eingeladen wurde + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const existingInvite = await Invitation.findOne({ + where: { + email: inviteeEmail, + created_at: { + [Op.gte]: today + } + } + }); + + if (existingInvite) { + throw new Error('Diese Email-Adresse wurde heute bereits eingeladen. Bitte versuchen Sie es morgen erneut.'); + } + + // Prüfe ob Email bereits registriert ist + const { AuthInfo } = database.getModels(); + const existingUser = await AuthInfo.findOne({ + where: { email: inviteeEmail } + }); + + if (existingUser) { + throw new Error('Diese Email-Adresse ist bereits registriert'); + } + + // Erstelle Einladungs-Token + const token = this._generateToken(); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); // 7 Tage gültig + + // Speichere Einladung + const invitation = await Invitation.create({ + inviter_user_id: inviterUserId, + email: inviteeEmail, + token, + expires_at: expiresAt, + created_at: new Date(), + status: 'pending' + }); + + // Sende Email (falls konfiguriert) + if (this.emailTransporter) { + await this._sendInviteEmail(inviteeEmail, inviter.full_name, token); + } else { + console.warn('⚠️ Email-Transporter nicht konfiguriert - Einladung wurde gespeichert, aber keine Email versendet'); + } + + return { + id: invitation.id, + email: inviteeEmail, + token, + expiresAt, + status: 'sent' + }; + } + + /** + * Holt alle Einladungen eines Users (optional: nur aktive) + * @param {number} userId - User-ID + * @param {boolean} activeOnly - Nur aktive (nicht abgelaufene) Einladungen + * @returns {Promise} Array von Einladungen + */ + async getUserInvitations(userId, activeOnly = false) { + const { Invitation } = database.getModels(); + + const where = { + inviter_user_id: userId + }; + + if (activeOnly) { + where.expires_at = { [Op.gt]: new Date() }; + where.status = 'pending'; + } + + const invitations = await Invitation.findAll({ + where, + order: [['created_at', 'DESC']], + raw: true + }); + + return invitations.map(inv => ({ + id: inv.id, + email: inv.email, + createdAt: inv.created_at, + expiresAt: inv.expires_at, + status: inv.status, + isExpired: new Date() > new Date(inv.expires_at) + })); + } + + /** + * Validiert Email-Format + */ + _isValidEmail(email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + } + + /** + * Generiert einen zufälligen Token + */ + _generateToken() { + return require('crypto').randomBytes(32).toString('hex'); + } + + /** + * Sendet die Einladungs-Email + */ + async _sendInviteEmail(toEmail, inviterName, token) { + const appUrl = process.env.APP_URL || 'http://localhost:5010'; + const registerUrl = `${appUrl}/register?token=${token}`; + + const mailOptions = { + from: process.env.EMAIL_FROM || process.env.EMAIL_USER, + to: toEmail, + subject: 'Einladung zur Stechuhr - Zeiterfassungssystem', + html: ` +

Einladung zur Stechuhr

+

Hallo,

+

${inviterName} hat Sie eingeladen, die Stechuhr-Zeiterfassung zu nutzen.

+

Klicken Sie auf den folgenden Link, um Ihren Account zu erstellen:

+

Account erstellen

+

Oder kopieren Sie diesen Link in Ihren Browser:

+

${registerUrl}

+

Diese Einladung ist 7 Tage gültig.

+
+

+ Wenn Sie diese Einladung nicht erwartet haben, können Sie diese Email ignorieren. +

+ ` + }; + + await this.emailTransporter.sendMail(mailOptions); + console.log(`✉️ Einladungs-Email an ${toEmail} versendet`); + } +} + +module.exports = new InviteService(); + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index bff6449..3b33358 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -77,6 +77,7 @@ const pageTitle = computed(() => { 'settings-profile': 'Persönliches', 'settings-password': 'Passwort ändern', 'settings-timewish': 'Zeitwünsche', + 'settings-invite': 'Einladen', 'entries': 'Einträge', 'stats': 'Statistiken' } diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 3169285..2353782 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -20,6 +20,7 @@ import Profile from '../views/Profile.vue' import PasswordChange from '../views/PasswordChange.vue' import Timewish from '../views/Timewish.vue' import Roles from '../views/Roles.vue' +import Invite from '../views/Invite.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -126,6 +127,12 @@ const router = createRouter({ component: Timewish, meta: { requiresAuth: true } }, + { + path: '/settings/invite', + name: 'settings-invite', + component: Invite, + meta: { requiresAuth: true } + }, { path: '/entries', name: 'entries', diff --git a/frontend/src/views/Invite.vue b/frontend/src/views/Invite.vue new file mode 100644 index 0000000..7a42602 --- /dev/null +++ b/frontend/src/views/Invite.vue @@ -0,0 +1,342 @@ + + + + + + diff --git a/frontend/src/views/Workdays.vue b/frontend/src/views/Workdays.vue index 9262fa5..7336a85 100644 --- a/frontend/src/views/Workdays.vue +++ b/frontend/src/views/Workdays.vue @@ -144,7 +144,8 @@ onMounted(() => { border-bottom: none; } -.stats-table th { +.stats-table th, +.stats-table td { text-align: left; padding: 12px 16px; font-weight: 600;