Initial commit: TimeClock v3 - Node.js/Vue.js Zeiterfassung

Features:
- Backend: Node.js/Express mit MySQL/MariaDB
- Frontend: Vue.js 3 mit Composition API
- UTC-Zeithandling für korrekte Zeiterfassung
- Timewish-basierte Überstundenberechnung
- Wochenübersicht mit Urlaubs-/Krankheits-/Feiertagshandling
- Bereinigtes Arbeitsende (Generell/Woche)
- Überstunden-Offset für historische Daten
- Fixed Layout mit scrollbarem Content
- Kompakte UI mit grünem Theme
This commit is contained in:
Torsten Schulz (local)
2025-10-17 14:11:28 +02:00
commit e95bb4cb76
86 changed files with 19530 additions and 0 deletions

View File

@@ -0,0 +1,256 @@
const authService = require('../services/AuthService');
/**
* Authentication Controller
* Verwaltet Auth-bezogene HTTP-Requests
*/
class AuthController {
/**
* Benutzer registrieren
* POST /api/auth/register
*/
async register(req, res) {
try {
const { email, password, full_name } = req.body;
const result = await authService.register({
email,
password,
full_name
});
res.status(201).json({
success: true,
message: 'Registrierung erfolgreich',
user: result.user
});
} catch (error) {
console.error('Registrierungsfehler:', error);
res.status(400).json({
success: false,
error: error.message
});
}
}
/**
* Benutzer einloggen
* POST /api/auth/login
*/
async login(req, res) {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({
success: false,
error: 'E-Mail und Passwort sind erforderlich'
});
}
const result = await authService.login(email, password);
res.json({
success: true,
message: 'Login erfolgreich',
token: result.token,
user: result.user
});
} catch (error) {
console.error('Login-Fehler:', error);
res.status(401).json({
success: false,
error: error.message
});
}
}
/**
* Ausloggen
* POST /api/auth/logout
*/
async logout(req, res) {
try {
const token = req.headers.authorization?.replace('Bearer ', '');
if (token) {
await authService.logout(token);
}
res.json({
success: true,
message: 'Logout erfolgreich'
});
} catch (error) {
console.error('Logout-Fehler:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Logout'
});
}
}
/**
* Aktuellen Benutzer abrufen
* GET /api/auth/me
*/
async getCurrentUser(req, res) {
try {
const userId = req.user.userId;
const profile = await authService.getUserProfile(userId);
res.json({
success: true,
user: profile
});
} catch (error) {
console.error('Fehler beim Abrufen des Benutzers:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Abrufen der Benutzerdaten'
});
}
}
/**
* Passwort-Reset anfordern
* POST /api/auth/request-reset
*/
async requestPasswordReset(req, res) {
try {
const { email } = req.body;
if (!email) {
return res.status(400).json({
success: false,
error: 'E-Mail ist erforderlich'
});
}
const resetToken = await authService.requestPasswordReset(email);
// Debug-Logs
console.log('Reset-Token:', resetToken);
console.log('NODE_ENV:', process.env.NODE_ENV);
console.log('isDevelopment:', !process.env.NODE_ENV || process.env.NODE_ENV === 'development');
// In Produktion: E-Mail mit Reset-Link senden
// Für Entwicklung: Token zurückgeben (auch wenn E-Mail nicht existiert)
const isDevelopment = !process.env.NODE_ENV || process.env.NODE_ENV === 'development';
if (isDevelopment) {
// Im Development-Modus immer einen Token zurückgeben für Testing
const devToken = resetToken || 'dev-test-token-' + Date.now() + '-' + email;
res.json({
success: true,
message: 'Reset-Link wurde gesendet (DEV)',
resetToken: devToken // NUR FÜR ENTWICKLUNG!
});
} else {
res.json({
success: true,
message: 'Falls ein Account mit dieser E-Mail existiert, wurde ein Reset-Link gesendet'
});
}
} catch (error) {
console.error('Fehler bei Passwort-Reset-Anfrage:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Senden des Reset-Links'
});
}
}
/**
* Passwort zurücksetzen
* POST /api/auth/reset-password
*/
async resetPassword(req, res) {
try {
const { token, password } = req.body;
if (!token || !password) {
return res.status(400).json({
success: false,
error: 'Token und neues Passwort sind erforderlich'
});
}
await authService.resetPassword(token, password);
res.json({
success: true,
message: 'Passwort wurde erfolgreich zurückgesetzt'
});
} catch (error) {
console.error('Fehler beim Passwort-Reset:', error);
res.status(400).json({
success: false,
error: error.message
});
}
}
/**
* Passwort ändern (eingeloggter Benutzer)
* POST /api/auth/change-password
*/
async changePassword(req, res) {
try {
const userId = req.user.userId;
const { oldPassword, newPassword } = req.body;
if (!oldPassword || !newPassword) {
return res.status(400).json({
success: false,
error: 'Altes und neues Passwort sind erforderlich'
});
}
await authService.changePassword(userId, oldPassword, newPassword);
res.json({
success: true,
message: 'Passwort wurde erfolgreich geändert'
});
} catch (error) {
console.error('Fehler beim Passwort-Ändern:', error);
res.status(400).json({
success: false,
error: error.message
});
}
}
/**
* Token validieren
* GET /api/auth/validate
*/
async validateToken(req, res) {
try {
// Wenn Middleware durchgelaufen ist, ist Token valid
res.json({
success: true,
valid: true,
user: req.user
});
} catch (error) {
res.status(401).json({
success: false,
valid: false,
error: 'Ungültiger Token'
});
}
}
}
module.exports = new AuthController();

View File

@@ -0,0 +1,101 @@
const passport = require('passport');
/**
* OAuth Controller
* Verwaltet OAuth-Logins
*/
class OAuthController {
/**
* Google OAuth initiieren
* GET /api/auth/google
*/
googleAuth(req, res, next) {
passport.authenticate('google', {
scope: ['profile', 'email'],
session: false
})(req, res, next);
}
/**
* Google OAuth Callback
* GET /api/auth/google/callback
*/
googleCallback(req, res, next) {
passport.authenticate('google', {
session: false,
failureRedirect: `${process.env.FRONTEND_URL || 'http://localhost:5010'}/login?error=oauth_failed`
}, (err, result) => {
if (err || !result) {
console.error('Google OAuth Fehler:', err);
return res.redirect(`${process.env.FRONTEND_URL || 'http://localhost:5010'}/login?error=oauth_failed`);
}
// Redirect zum Frontend mit Token
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:5010';
res.redirect(`${frontendUrl}/oauth-callback?token=${result.token}`);
})(req, res, next);
}
/**
* OAuth-Identities für Benutzer abrufen
* GET /api/auth/identities
*/
async getIdentities(req, res) {
try {
const userId = req.user.userId;
const oauthService = require('../services/OAuthService');
const identities = await oauthService.getUserIdentities(userId);
res.json({
success: true,
identities
});
} catch (error) {
console.error('Fehler beim Abrufen der Identities:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Abrufen der OAuth-Verknüpfungen'
});
}
}
/**
* OAuth-Provider entfernen
* DELETE /api/auth/identity/:provider
*/
async unlinkProvider(req, res) {
try {
const userId = req.user.userId;
const { provider } = req.params;
const oauthService = require('../services/OAuthService');
const unlinked = await oauthService.unlinkProvider(userId, provider);
if (unlinked) {
res.json({
success: true,
message: `${provider} wurde erfolgreich entfernt`
});
} else {
res.status(404).json({
success: false,
error: 'Verknüpfung nicht gefunden'
});
}
} catch (error) {
console.error('Fehler beim Entfernen der Identity:', error);
res.status(500).json({
success: false,
error: error.message
});
}
}
}
module.exports = new OAuthController();

View File

@@ -0,0 +1,265 @@
const timeEntryService = require('../services/TimeEntryService');
/**
* Controller-Klasse für Zeiteinträge
* Verantwortlich für HTTP-Request/Response-Handling
*/
class TimeEntryController {
/**
* Alle Zeiteinträge abrufen
* GET /api/time-entries
*/
async getAllEntries(req, res) {
try {
const entries = timeEntryService.getAllEntries();
res.json(entries);
} catch (error) {
console.error('Fehler beim Abrufen der Einträge:', error);
res.status(500).json({
error: 'Fehler beim Abrufen der Einträge',
message: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
}
/**
* Einzelnen Zeiteintrag anhand der ID abrufen
* GET /api/time-entries/:id
*/
async getEntryById(req, res) {
try {
const { id } = req.params;
const entry = timeEntryService.getEntryById(id);
if (!entry) {
return res.status(404).json({
error: 'Eintrag nicht gefunden',
id: parseInt(id)
});
}
res.json(entry);
} catch (error) {
console.error('Fehler beim Abrufen des Eintrags:', error);
res.status(500).json({
error: 'Fehler beim Abrufen des Eintrags',
message: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
}
/**
* Neuen Zeiteintrag erstellen (Timer starten)
* POST /api/time-entries
*/
async createEntry(req, res) {
try {
const entryData = req.body;
const newEntry = timeEntryService.createEntry(entryData);
res.status(201).json(newEntry);
} catch (error) {
console.error('Fehler beim Erstellen des Eintrags:', error);
// Spezifische Fehlerbehandlung
if (error.message.includes('läuft bereits')) {
return res.status(400).json({
error: 'Validierungsfehler',
message: error.message
});
}
res.status(500).json({
error: 'Fehler beim Erstellen des Eintrags',
message: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
}
/**
* Zeiteintrag aktualisieren (Timer stoppen oder Daten ändern)
* PUT /api/time-entries/:id
*/
async updateEntry(req, res) {
try {
const { id } = req.params;
const updateData = req.body;
const updatedEntry = timeEntryService.updateEntry(id, updateData);
res.json(updatedEntry);
} catch (error) {
console.error('Fehler beim Aktualisieren des Eintrags:', error);
// Spezifische Fehlerbehandlung
if (error.message.includes('nicht gefunden')) {
return res.status(404).json({
error: 'Eintrag nicht gefunden',
message: error.message
});
}
if (error.message.includes('Endzeit') || error.message.includes('Startzeit')) {
return res.status(400).json({
error: 'Validierungsfehler',
message: error.message
});
}
res.status(500).json({
error: 'Fehler beim Aktualisieren des Eintrags',
message: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
}
/**
* Zeiteintrag löschen
* DELETE /api/time-entries/:id
*/
async deleteEntry(req, res) {
try {
const { id } = req.params;
timeEntryService.deleteEntry(id);
res.status(204).send();
} catch (error) {
console.error('Fehler beim Löschen des Eintrags:', error);
if (error.message.includes('nicht gefunden')) {
return res.status(404).json({
error: 'Eintrag nicht gefunden',
message: error.message
});
}
res.status(500).json({
error: 'Fehler beim Löschen des Eintrags',
message: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
}
/**
* Aktuellen Status abrufen (letzter Worklog-Eintrag)
* GET /api/time-entries/current-state
*/
async getCurrentState(req, res) {
try {
const userId = req.user.userId;
const state = await timeEntryService.getCurrentState(userId);
res.json({
success: true,
state: state
});
} catch (error) {
console.error('Fehler beim Abrufen des aktuellen Status:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Abrufen des Status'
});
}
}
/**
* Stempeln (Clock In/Out, Pause Start/Stop)
* POST /api/time-entries/clock
*/
async clock(req, res) {
try {
const userId = req.user.userId;
const { action } = req.body;
if (!action) {
return res.status(400).json({
success: false,
error: 'Aktion ist erforderlich'
});
}
const result = await timeEntryService.clock(userId, action);
res.json({
success: true,
message: 'Erfolgreich gestempelt',
entry: result
});
} catch (error) {
console.error('Fehler beim Stempeln:', error);
res.status(400).json({
success: false,
error: error.message
});
}
}
/**
* Statistiken abrufen
* GET /api/time-entries/stats/summary
*/
async getStats(req, res) {
try {
const userId = req.user?.userId;
const stats = await timeEntryService.getStatistics(userId);
res.json(stats || {});
} catch (error) {
console.error('Fehler beim Abrufen der Statistiken:', error);
res.status(500).json({
error: 'Fehler beim Abrufen der Statistiken',
message: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
}
/**
* Aktuell laufenden Timer abrufen
* GET /api/time-entries/running
*/
async getRunningEntry(req, res) {
try {
const userId = req.user.userId;
const runningEntry = await timeEntryService.getRunningEntry(userId);
if (!runningEntry) {
return res.json({}); // Leeres Objekt wenn nichts läuft
}
res.json(runningEntry);
} catch (error) {
console.error('Fehler beim Abrufen des laufenden Timers:', error);
res.status(500).json({
error: 'Fehler beim Abrufen des laufenden Timers',
message: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
}
/**
* Einträge nach Projekt filtern
* GET /api/time-entries/project/:projectName
*/
async getEntriesByProject(req, res) {
try {
const { projectName } = req.params;
const entries = timeEntryService.getEntriesByProject(projectName);
res.json({
project: projectName,
count: entries.length,
entries
});
} catch (error) {
console.error('Fehler beim Filtern nach Projekt:', error);
res.status(500).json({
error: 'Fehler beim Filtern nach Projekt',
message: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
}
}
// Singleton-Instanz exportieren
module.exports = new TimeEntryController();

View File

@@ -0,0 +1,35 @@
const timeEntryService = require('../services/TimeEntryService');
/**
* WeekOverview Controller
* Verwaltet Wochenübersicht-Daten
*/
class WeekOverviewController {
/**
* Wochenübersicht für einen Benutzer abrufen
* GET /api/week-overview
*/
async getWeekOverview(req, res) {
try {
const userId = req.user.userId;
const { weekOffset = 0 } = req.query; // 0 = aktuelle Woche, -1 = letzte Woche, etc.
const weekData = await timeEntryService.getWeekOverview(userId, parseInt(weekOffset));
res.json({
success: true,
data: weekData
});
} catch (error) {
console.error('Fehler beim Abrufen der Wochenübersicht:', error);
res.status(500).json({
success: false,
error: 'Fehler beim Laden der Wochenübersicht'
});
}
}
}
module.exports = new WeekOverviewController();