From ac3720fb61f390995a676cd0f33fb005e95211c4 Mon Sep 17 00:00:00 2001 From: "Torsten Schulz (local)" Date: Fri, 17 Oct 2025 23:50:25 +0200 Subject: [PATCH] Add watcher routes to backend and frontend; implement routing and UI components for watcher management --- backend/create-watcher-table.sql | 28 ++ backend/src/config/database.js | 8 +- backend/src/controllers/WatcherController.js | 73 ++++ backend/src/index.js | 4 + backend/src/models/Watcher.js | 79 ++++ backend/src/models/index.js | 4 +- backend/src/routes/watcher.js | 20 + backend/src/services/WatcherService.js | 139 +++++++ frontend/src/App.vue | 1 + frontend/src/components/SideMenu.vue | 9 +- frontend/src/router/index.js | 7 + frontend/src/views/Permissions.vue | 388 +++++++++++++++++++ 12 files changed, 750 insertions(+), 10 deletions(-) create mode 100644 backend/create-watcher-table.sql create mode 100644 backend/src/controllers/WatcherController.js create mode 100644 backend/src/models/Watcher.js create mode 100644 backend/src/routes/watcher.js create mode 100644 backend/src/services/WatcherService.js create mode 100644 frontend/src/views/Permissions.vue diff --git a/backend/create-watcher-table.sql b/backend/create-watcher-table.sql new file mode 100644 index 0000000..544f519 --- /dev/null +++ b/backend/create-watcher-table.sql @@ -0,0 +1,28 @@ +-- SQL Script: Watcher-Tabelle erstellen +-- Speichert Email-Adressen, die die Arbeitszeiten eines Users sehen dürfen +-- Ausführen mit: mysql -u root -p stechuhr < create-watcher-table.sql + +USE stechuhr; + +-- Erstelle Watcher-Tabelle falls nicht vorhanden +CREATE TABLE IF NOT EXISTS watcher ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL COMMENT 'User dessen Arbeitszeiten gesehen werden dürfen', + watcher_email VARCHAR(255) NOT NULL COMMENT 'Email-Adresse der berechtigten Person', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + + -- Foreign Keys + CONSTRAINT fk_watcher_user FOREIGN KEY (user_id) + REFERENCES user(id) ON DELETE CASCADE, + + -- Indices für Performance + INDEX idx_watcher_user (user_id), + INDEX idx_watcher_email (watcher_email), + + -- Unique constraint: Ein Watcher pro User/Email-Kombination + UNIQUE KEY idx_watcher_user_email (user_id, watcher_email) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +SELECT 'Watcher-Tabelle erfolgreich erstellt oder bereits vorhanden.' AS status; + diff --git a/backend/src/config/database.js b/backend/src/config/database.js index 36bdafe..e5b8ed6 100644 --- a/backend/src/config/database.js +++ b/backend/src/config/database.js @@ -83,6 +83,7 @@ class Database { const Timefix = require('../models/Timefix'); const Timewish = require('../models/Timewish'); const Invitation = require('../models/Invitation'); + const Watcher = require('../models/Watcher'); // Models mit Sequelize-Instanz initialisieren User.initialize(this.sequelize); @@ -100,6 +101,7 @@ class Database { Timefix.initialize(this.sequelize); Timewish.initialize(this.sequelize); Invitation.initialize(this.sequelize); + Watcher.initialize(this.sequelize); // Assoziationen definieren this.defineAssociations(); @@ -111,7 +113,7 @@ class Database { * Model-Assoziationen definieren */ defineAssociations() { - const { User, Worklog, AuthInfo, AuthToken, AuthIdentity, State, WeeklyWorktime, Holiday, HolidayState, Vacation, Sick, SickType, Timefix, Timewish, Invitation } = this.sequelize.models; + const { User, Worklog, AuthInfo, AuthToken, AuthIdentity, State, WeeklyWorktime, Holiday, HolidayState, Vacation, Sick, SickType, Timefix, Timewish, Invitation, Watcher } = this.sequelize.models; // User Assoziationen User.hasMany(Worklog, { foreignKey: 'user_id', as: 'worklogs' }); @@ -181,6 +183,10 @@ class Database { // Invitation Assoziationen Invitation.belongsTo(User, { foreignKey: 'inviter_user_id', as: 'inviter' }); User.hasMany(Invitation, { foreignKey: 'inviter_user_id', as: 'invitations' }); + + // Watcher Assoziationen + Watcher.belongsTo(User, { foreignKey: 'user_id', as: 'user' }); + User.hasMany(Watcher, { foreignKey: 'user_id', as: 'watchers' }); } /** diff --git a/backend/src/controllers/WatcherController.js b/backend/src/controllers/WatcherController.js new file mode 100644 index 0000000..c1211dc --- /dev/null +++ b/backend/src/controllers/WatcherController.js @@ -0,0 +1,73 @@ +const WatcherService = require('../services/WatcherService'); + +/** + * Controller für Watcher (Berechtigungen) + * Verarbeitet HTTP-Requests und delegiert an WatcherService + */ +class WatcherController { + /** + * Holt alle Watcher des aktuellen Users + */ + async getWatchers(req, res) { + try { + const userId = req.user?.id || 1; + const watchers = await WatcherService.getWatchers(userId); + res.json(watchers); + } catch (error) { + console.error('Fehler beim Abrufen der Beobachter:', error); + res.status(500).json({ + message: 'Fehler beim Abrufen der Beobachter', + error: error.message + }); + } + } + + /** + * Fügt einen Watcher hinzu + */ + async addWatcher(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 watcher = await WatcherService.addWatcher(userId, email); + res.status(201).json(watcher); + } catch (error) { + console.error('Fehler beim Hinzufügen des Beobachters:', error); + res.status(error.message.includes('bereits eingetragen') ? 409 : 500).json({ + message: error.message + }); + } + } + + /** + * Entfernt einen Watcher + */ + async removeWatcher(req, res) { + try { + const userId = req.user?.id || 1; + const watcherId = parseInt(req.params.id); + + if (isNaN(watcherId)) { + return res.status(400).json({ message: 'Ungültige ID' }); + } + + await WatcherService.removeWatcher(userId, watcherId); + res.json({ message: 'Beobachter entfernt' }); + } catch (error) { + console.error('Fehler beim Entfernen des Beobachters:', error); + res.status(error.message.includes('nicht gefunden') ? 404 : 500).json({ + message: error.message + }); + } + } +} + +module.exports = new WatcherController(); + diff --git a/backend/src/index.js b/backend/src/index.js index b8bdad0..a54afa0 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -110,6 +110,10 @@ app.use('/api/roles', authenticateToken, rolesRouter); const inviteRouter = require('./routes/invite'); app.use('/api/invite', authenticateToken, inviteRouter); +// Watcher routes (geschützt) - MIT ID-Hashing +const watcherRouter = require('./routes/watcher'); +app.use('/api/watcher', authenticateToken, watcherRouter); + // Error handling middleware app.use((err, req, res, next) => { console.error(err.stack); diff --git a/backend/src/models/Watcher.js b/backend/src/models/Watcher.js new file mode 100644 index 0000000..b364d07 --- /dev/null +++ b/backend/src/models/Watcher.js @@ -0,0 +1,79 @@ +const { Model, DataTypes } = require('sequelize'); + +/** + * Watcher Model + * Repräsentiert Personen, die die Arbeitszeiten eines Users sehen dürfen + */ +class Watcher extends Model { + static initialize(sequelize) { + Watcher.init( + { + id: { + type: DataTypes.BIGINT, + primaryKey: true, + autoIncrement: true, + allowNull: false + }, + user_id: { + type: DataTypes.BIGINT, + allowNull: false, + references: { + model: 'user', + key: 'id' + }, + comment: 'Der User dessen Arbeitszeiten gesehen werden dürfen' + }, + watcher_email: { + type: DataTypes.STRING(255), + allowNull: false, + comment: 'Email-Adresse der Person, die sehen darf' + }, + created_at: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW + }, + is_active: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true + } + }, + { + sequelize, + tableName: 'watcher', + timestamps: false, + indexes: [ + { + name: 'idx_watcher_user', + fields: ['user_id'] + }, + { + name: 'idx_watcher_email', + fields: ['watcher_email'] + }, + { + name: 'idx_watcher_user_email', + fields: ['user_id', 'watcher_email'], + unique: true + } + ] + } + ); + + return Watcher; + } + + /** + * Definiert Assoziationen mit anderen Models + */ + static associate(models) { + Watcher.belongsTo(models.User, { + foreignKey: 'user_id', + as: 'user' + }); + } +} + +module.exports = Watcher; + diff --git a/backend/src/models/index.js b/backend/src/models/index.js index a23e9fd..788c966 100644 --- a/backend/src/models/index.js +++ b/backend/src/models/index.js @@ -16,6 +16,7 @@ const SickType = require('./SickType'); const Timefix = require('./Timefix'); const Timewish = require('./Timewish'); const Invitation = require('./Invitation'); +const Watcher = require('./Watcher'); module.exports = { User, @@ -30,6 +31,7 @@ module.exports = { SickType, Timefix, Timewish, - Invitation + Invitation, + Watcher }; diff --git a/backend/src/routes/watcher.js b/backend/src/routes/watcher.js new file mode 100644 index 0000000..5ce8c7e --- /dev/null +++ b/backend/src/routes/watcher.js @@ -0,0 +1,20 @@ +const express = require('express'); +const router = express.Router(); +const WatcherController = require('../controllers/WatcherController'); +const unhashRequestIds = require('../middleware/unhashRequest'); + +/** + * Routen für Watcher (Berechtigungen) + */ + +// GET /api/watcher - Alle Watcher abrufen +router.get('/', WatcherController.getWatchers.bind(WatcherController)); + +// POST /api/watcher - Neuen Watcher hinzufügen +router.post('/', WatcherController.addWatcher.bind(WatcherController)); + +// DELETE /api/watcher/:id - Watcher entfernen +router.delete('/:id', unhashRequestIds, WatcherController.removeWatcher.bind(WatcherController)); + +module.exports = router; + diff --git a/backend/src/services/WatcherService.js b/backend/src/services/WatcherService.js new file mode 100644 index 0000000..80c7e2f --- /dev/null +++ b/backend/src/services/WatcherService.js @@ -0,0 +1,139 @@ +const database = require('../config/database'); + +/** + * Service-Klasse für Watcher (Berechtigungen) + * Verwaltet, wer die Arbeitszeiten eines Users sehen darf + */ +class WatcherService { + /** + * Holt alle Watcher eines Users + * @param {number} userId - User-ID + * @returns {Promise} Array von Watchern + */ + async getWatchers(userId) { + const { Watcher } = database.getModels(); + + const watchers = await Watcher.findAll({ + where: { + user_id: userId, + is_active: true + }, + order: [['created_at', 'DESC']], + raw: true + }); + + return watchers.map(w => ({ + id: w.id, + email: w.watcher_email, + createdAt: w.created_at ? new Date(w.created_at).toISOString() : null + })); + } + + /** + * Fügt einen Watcher hinzu + * @param {number} userId - User-ID + * @param {string} watcherEmail - Email-Adresse des Watchers + * @returns {Promise} Erstellter Watcher + */ + async addWatcher(userId, watcherEmail) { + const { Watcher } = database.getModels(); + + // Validiere Email + if (!watcherEmail || !this._isValidEmail(watcherEmail)) { + throw new Error('Ungültige Email-Adresse'); + } + + // Prüfe ob bereits existiert + const existing = await Watcher.findOne({ + where: { + user_id: userId, + watcher_email: watcherEmail + } + }); + + if (existing) { + if (existing.is_active) { + throw new Error('Diese Email-Adresse ist bereits als Beobachter eingetragen'); + } else { + // Reaktiviere inaktiven Watcher + existing.is_active = true; + await existing.save(); + return { + id: existing.id, + email: existing.watcher_email, + createdAt: existing.created_at + }; + } + } + + // Erstelle neuen Watcher + const watcher = await Watcher.create({ + user_id: userId, + watcher_email: watcherEmail, + is_active: true, + created_at: new Date() + }); + + return { + id: watcher.id, + email: watcher.watcher_email, + createdAt: watcher.created_at + }; + } + + /** + * Entfernt einen Watcher (soft delete) + * @param {number} userId - User-ID + * @param {number} watcherId - Watcher-ID + * @returns {Promise} + */ + async removeWatcher(userId, watcherId) { + const { Watcher } = database.getModels(); + + const watcher = await Watcher.findByPk(watcherId); + + if (!watcher) { + throw new Error('Beobachter nicht gefunden'); + } + + // Prüfe Berechtigung + if (watcher.user_id !== userId) { + throw new Error('Keine Berechtigung für diesen Eintrag'); + } + + // Soft delete: Setze is_active auf false + watcher.is_active = false; + await watcher.save(); + } + + /** + * Prüft ob eine Email die Arbeitszeiten eines Users sehen darf + * @param {number} userId - User-ID + * @param {string} watcherEmail - Email-Adresse des Watchers + * @returns {Promise} True wenn berechtigt + */ + async canWatch(userId, watcherEmail) { + const { Watcher } = database.getModels(); + + const watcher = await Watcher.findOne({ + where: { + user_id: userId, + watcher_email: watcherEmail, + is_active: true + } + }); + + return !!watcher; + } + + /** + * Validiert Email-Format + */ + _isValidEmail(email) { + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); + } +} + +module.exports = new WatcherService(); + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3b33358..be117bd 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -78,6 +78,7 @@ const pageTitle = computed(() => { 'settings-password': 'Passwort ändern', 'settings-timewish': 'Zeitwünsche', 'settings-invite': 'Einladen', + 'settings-permissions': 'Berechtigungen', 'entries': 'Einträge', 'stats': 'Statistiken' } diff --git a/frontend/src/components/SideMenu.vue b/frontend/src/components/SideMenu.vue index 13677d3..ae64a8d 100644 --- a/frontend/src/components/SideMenu.vue +++ b/frontend/src/components/SideMenu.vue @@ -69,14 +69,6 @@ const SECTIONS_USER = [ { label: 'Kalender', to: '/calendar' } ] }, - { - title: 'Andere Nutzer', - hasDropdown: true, - items: [ - { label: 'Liste mit Nutzernamen', to: '/users' }, - { label: 'Berechtigungen verteilen', to: '/users/permissions' } - ] - }, { title: 'Export', hasDropdown: false, @@ -89,6 +81,7 @@ const SECTIONS_USER = [ { label: 'Persönliches', to: '/settings/profile' }, { label: 'Paßwort ändern', to: '/settings/password' }, { label: 'Zeitwünsche', to: '/settings/timewish' }, + { label: 'Zugriffe verwalten', to: '/settings/permissions' }, { label: 'Einladen', to: '/settings/invite' } ] } diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 2353782..873f1aa 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -21,6 +21,7 @@ import PasswordChange from '../views/PasswordChange.vue' import Timewish from '../views/Timewish.vue' import Roles from '../views/Roles.vue' import Invite from '../views/Invite.vue' +import Permissions from '../views/Permissions.vue' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -133,6 +134,12 @@ const router = createRouter({ component: Invite, meta: { requiresAuth: true } }, + { + path: '/settings/permissions', + name: 'settings-permissions', + component: Permissions, + meta: { requiresAuth: true } + }, { path: '/entries', name: 'entries', diff --git a/frontend/src/views/Permissions.vue b/frontend/src/views/Permissions.vue new file mode 100644 index 0000000..e7659da --- /dev/null +++ b/frontend/src/views/Permissions.vue @@ -0,0 +1,388 @@ + + + + + +