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:
453
backend/src/services/AuthService.js
Normal file
453
backend/src/services/AuthService.js
Normal file
@@ -0,0 +1,453 @@
|
||||
const bcrypt = require('bcrypt');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const database = require('../config/database');
|
||||
|
||||
/**
|
||||
* Authentication Service
|
||||
* Verwaltet Login, Registrierung, Passwort-Reset
|
||||
*/
|
||||
class AuthService {
|
||||
constructor() {
|
||||
this.jwtSecret = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
this.jwtExpiration = process.env.JWT_EXPIRATION || '24h';
|
||||
this.saltRounds = 10;
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer registrieren
|
||||
* @param {Object} userData - Registrierungsdaten
|
||||
* @returns {Promise<Object>} Erstellter Benutzer mit Auth-Info
|
||||
*/
|
||||
async register(userData) {
|
||||
const { User, AuthInfo } = database.getModels();
|
||||
const { email, password, full_name } = userData;
|
||||
|
||||
// Validierung
|
||||
if (!email || !password || !full_name) {
|
||||
throw new Error('E-Mail, Passwort und Name sind erforderlich');
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
throw new Error('Passwort muss mindestens 6 Zeichen lang sein');
|
||||
}
|
||||
|
||||
// Prüfen ob E-Mail bereits existiert
|
||||
const existingAuth = await AuthInfo.findOne({ where: { email } });
|
||||
if (existingAuth) {
|
||||
throw new Error('Diese E-Mail-Adresse ist bereits registriert');
|
||||
}
|
||||
|
||||
// Passwort hashen
|
||||
const salt = await bcrypt.genSalt(this.saltRounds);
|
||||
const passwordHash = await bcrypt.hash(password, salt);
|
||||
|
||||
// Benutzer erstellen
|
||||
const user = await User.create({
|
||||
full_name,
|
||||
role: 0,
|
||||
daily_hours: 8,
|
||||
week_hours: 40,
|
||||
week_workdays: 5,
|
||||
preferred_title_type: 0,
|
||||
version: 0,
|
||||
last_change: new Date()
|
||||
});
|
||||
|
||||
// Auth-Info erstellen
|
||||
const authInfo = await AuthInfo.create({
|
||||
user_id: user.id,
|
||||
email,
|
||||
password_hash: passwordHash,
|
||||
password_method: 'bcrypt',
|
||||
password_salt: salt,
|
||||
status: 1, // Active
|
||||
failed_login_attempts: 0,
|
||||
email_token: '',
|
||||
email_token_role: 0,
|
||||
unverified_email: '',
|
||||
version: 0
|
||||
});
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
full_name: user.full_name,
|
||||
email: authInfo.email
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer einloggen
|
||||
* @param {string} email - E-Mail-Adresse
|
||||
* @param {string} password - Passwort
|
||||
* @returns {Promise<Object>} Token und Benutzer-Info
|
||||
*/
|
||||
async login(email, password) {
|
||||
const { User, AuthInfo, AuthToken } = database.getModels();
|
||||
|
||||
console.log('Login-Versuch für E-Mail:', email);
|
||||
|
||||
// Auth-Info mit Benutzer laden
|
||||
const authInfo = await AuthInfo.findOne({
|
||||
where: { email },
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'user',
|
||||
required: true
|
||||
}]
|
||||
});
|
||||
|
||||
console.log('AuthInfo gefunden:', !!authInfo);
|
||||
if (authInfo) {
|
||||
console.log('AuthInfo E-Mail:', authInfo.email);
|
||||
console.log('User ID:', authInfo.user?.id);
|
||||
}
|
||||
|
||||
if (!authInfo) {
|
||||
throw new Error('Ungültige E-Mail oder Passwort');
|
||||
}
|
||||
|
||||
// Account-Status prüfen
|
||||
if (authInfo.status !== 1) {
|
||||
throw new Error('Ihr Account ist deaktiviert. Bitte kontaktieren Sie den Support.');
|
||||
}
|
||||
|
||||
// Zu viele fehlgeschlagene Versuche?
|
||||
if (authInfo.failed_login_attempts >= 5) {
|
||||
const lastAttempt = authInfo.last_login_attempt;
|
||||
const lockoutTime = 15 * 60 * 1000; // 15 Minuten
|
||||
|
||||
if (lastAttempt && (Date.now() - new Date(lastAttempt).getTime()) < lockoutTime) {
|
||||
throw new Error('Account temporär gesperrt. Bitte versuchen Sie es später erneut.');
|
||||
} else {
|
||||
// Reset nach Lockout-Zeit
|
||||
await authInfo.update({
|
||||
failed_login_attempts: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Passwort verifizieren
|
||||
const isValidPassword = await bcrypt.compare(password, authInfo.password_hash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
// Fehlversuch registrieren
|
||||
await authInfo.update({
|
||||
failed_login_attempts: authInfo.failed_login_attempts + 1,
|
||||
last_login_attempt: new Date()
|
||||
});
|
||||
throw new Error('Ungültige E-Mail oder Passwort');
|
||||
}
|
||||
|
||||
// Login erfolgreich - Reset fehlgeschlagene Versuche
|
||||
await authInfo.update({
|
||||
failed_login_attempts: 0,
|
||||
last_login_attempt: new Date()
|
||||
});
|
||||
|
||||
// JWT Token generieren
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: authInfo.user.id,
|
||||
email: authInfo.email,
|
||||
role: authInfo.user.role
|
||||
},
|
||||
this.jwtSecret,
|
||||
{ expiresIn: this.jwtExpiration }
|
||||
);
|
||||
|
||||
// Token in Datenbank speichern
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 24);
|
||||
|
||||
await AuthToken.create({
|
||||
auth_info_id: authInfo.id,
|
||||
value: crypto.createHash('sha256').update(token).digest('hex'),
|
||||
expires: expiresAt,
|
||||
version: 0
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: authInfo.user.id,
|
||||
full_name: authInfo.user.full_name,
|
||||
email: authInfo.email,
|
||||
role: authInfo.user.role
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Token validieren
|
||||
* @param {string} token - JWT Token
|
||||
* @returns {Promise<Object>} Dekodierte Token-Daten
|
||||
*/
|
||||
async validateToken(token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, this.jwtSecret);
|
||||
|
||||
// Prüfen ob Token in DB existiert und nicht abgelaufen
|
||||
const { AuthToken } = database.getModels();
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
const dbToken = await AuthToken.findOne({
|
||||
where: { value: tokenHash }
|
||||
});
|
||||
|
||||
if (!dbToken || dbToken.isExpired()) {
|
||||
throw new Error('Token ungültig oder abgelaufen');
|
||||
}
|
||||
|
||||
return decoded;
|
||||
} catch (error) {
|
||||
throw new Error('Ungültiger Token');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort-Reset anfordern
|
||||
* @param {string} email - E-Mail-Adresse
|
||||
* @returns {Promise<string>} Reset-Token
|
||||
*/
|
||||
async requestPasswordReset(email) {
|
||||
const { AuthInfo } = database.getModels();
|
||||
|
||||
const authInfo = await AuthInfo.findOne({ where: { email } });
|
||||
|
||||
if (!authInfo) {
|
||||
// Aus Sicherheitsgründen keine Fehlermeldung, dass E-Mail nicht existiert
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reset-Token generieren
|
||||
const resetToken = crypto.randomBytes(32).toString('hex');
|
||||
const tokenExpires = new Date();
|
||||
tokenExpires.setHours(tokenExpires.getHours() + 1); // 1 Stunde gültig
|
||||
|
||||
// Token speichern
|
||||
await authInfo.update({
|
||||
email_token: resetToken,
|
||||
email_token_expires: tokenExpires,
|
||||
email_token_role: 1 // 1 = Password Reset
|
||||
});
|
||||
|
||||
return resetToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort zurücksetzen
|
||||
* @param {string} token - Reset-Token
|
||||
* @param {string} newPassword - Neues Passwort
|
||||
* @returns {Promise<boolean>} Erfolg
|
||||
*/
|
||||
async resetPassword(token, newPassword) {
|
||||
const { AuthInfo } = database.getModels();
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
throw new Error('Passwort muss mindestens 6 Zeichen lang sein');
|
||||
}
|
||||
|
||||
// Development-Token erkennen und behandeln
|
||||
const isDevelopment = !process.env.NODE_ENV || process.env.NODE_ENV === 'development';
|
||||
const isDevToken = token && token.startsWith('dev-test-token-');
|
||||
|
||||
if (isDevelopment && isDevToken) {
|
||||
// Im Development-Modus: Token akzeptieren und Passwort für spezifischen Benutzer ändern
|
||||
console.log('Development-Token erkannt:', token);
|
||||
|
||||
// E-Mail aus Token extrahieren (Format: dev-test-token-timestamp-email)
|
||||
// Token: "dev-test-token-1760554590751-tsschulz@tsschulz.de"
|
||||
const emailMatch = token.match(/dev-test-token-\d+-(.+)/);
|
||||
const email = emailMatch ? emailMatch[1] : null;
|
||||
|
||||
console.log('Development: Extrahierte E-Mail:', email);
|
||||
|
||||
if (email) {
|
||||
// Finde den Benutzer mit dieser E-Mail
|
||||
const authInfo = await AuthInfo.findOne({
|
||||
where: { email: email }
|
||||
});
|
||||
|
||||
if (authInfo) {
|
||||
console.log('Development: Ändere Passwort für Benutzer:', authInfo.email);
|
||||
|
||||
// Neues Passwort hashen und speichern
|
||||
const salt = await bcrypt.genSalt(this.saltRounds);
|
||||
const passwordHash = await bcrypt.hash(newPassword, salt);
|
||||
|
||||
await authInfo.update({
|
||||
password_hash: passwordHash,
|
||||
password_salt: salt,
|
||||
email_token: '', // Token löschen
|
||||
email_token_expires: null,
|
||||
email_token_role: 0
|
||||
});
|
||||
|
||||
console.log('Development: Passwort erfolgreich geändert für:', authInfo.email);
|
||||
return true;
|
||||
} else {
|
||||
console.log('Development: Kein Benutzer mit E-Mail', email, 'gefunden');
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Ändere Passwort für ersten Benutzer
|
||||
const authInfo = await AuthInfo.findOne({
|
||||
order: [['id', 'ASC']]
|
||||
});
|
||||
|
||||
if (authInfo) {
|
||||
console.log('Development: Fallback - Ändere Passwort für ersten Benutzer:', authInfo.email);
|
||||
|
||||
const salt = await bcrypt.genSalt(this.saltRounds);
|
||||
const passwordHash = await bcrypt.hash(newPassword, salt);
|
||||
|
||||
await authInfo.update({
|
||||
password_hash: passwordHash,
|
||||
password_salt: salt,
|
||||
email_token: '',
|
||||
email_token_expires: null,
|
||||
email_token_role: 0
|
||||
});
|
||||
|
||||
console.log('Development: Passwort erfolgreich geändert für:', authInfo.email);
|
||||
return true;
|
||||
} else {
|
||||
console.log('Development: Kein Benutzer in der Datenbank gefunden');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const authInfo = await AuthInfo.findOne({
|
||||
where: {
|
||||
email_token: token,
|
||||
email_token_role: 1
|
||||
}
|
||||
});
|
||||
|
||||
if (!authInfo) {
|
||||
throw new Error('Ungültiger oder abgelaufener Reset-Token');
|
||||
}
|
||||
|
||||
// Token-Ablauf prüfen
|
||||
if (authInfo.email_token_expires && new Date() > new Date(authInfo.email_token_expires)) {
|
||||
throw new Error('Reset-Token ist abgelaufen');
|
||||
}
|
||||
|
||||
// Neues Passwort hashen
|
||||
const salt = await bcrypt.genSalt(this.saltRounds);
|
||||
const passwordHash = await bcrypt.hash(newPassword, salt);
|
||||
|
||||
// Passwort aktualisieren und Token löschen
|
||||
await authInfo.update({
|
||||
password_hash: passwordHash,
|
||||
password_salt: salt,
|
||||
email_token: '',
|
||||
email_token_expires: null,
|
||||
email_token_role: 0,
|
||||
failed_login_attempts: 0
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Passwort ändern (für eingeloggten Benutzer)
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @param {string} oldPassword - Altes Passwort
|
||||
* @param {string} newPassword - Neues Passwort
|
||||
* @returns {Promise<boolean>} Erfolg
|
||||
*/
|
||||
async changePassword(userId, oldPassword, newPassword) {
|
||||
const { User, AuthInfo } = database.getModels();
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
throw new Error('Passwort muss mindestens 6 Zeichen lang sein');
|
||||
}
|
||||
|
||||
const user = await User.findByPk(userId, {
|
||||
include: [{
|
||||
model: AuthInfo,
|
||||
as: 'authInfo',
|
||||
required: true
|
||||
}]
|
||||
});
|
||||
|
||||
if (!user || !user.authInfo) {
|
||||
throw new Error('Benutzer nicht gefunden');
|
||||
}
|
||||
|
||||
// Altes Passwort verifizieren
|
||||
const isValidPassword = await bcrypt.compare(oldPassword, user.authInfo.password_hash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
throw new Error('Altes Passwort ist falsch');
|
||||
}
|
||||
|
||||
// Neues Passwort hashen
|
||||
const salt = await bcrypt.genSalt(this.saltRounds);
|
||||
const passwordHash = await bcrypt.hash(newPassword, salt);
|
||||
|
||||
// Passwort aktualisieren
|
||||
await user.authInfo.update({
|
||||
password_hash: passwordHash,
|
||||
password_salt: salt,
|
||||
version: user.authInfo.version + 1
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ausloggen (Token invalidieren)
|
||||
* @param {string} token - JWT Token
|
||||
* @returns {Promise<boolean>} Erfolg
|
||||
*/
|
||||
async logout(token) {
|
||||
const { AuthToken } = database.getModels();
|
||||
|
||||
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
|
||||
|
||||
await AuthToken.destroy({
|
||||
where: { value: tokenHash }
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Benutzer-Profil abrufen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @returns {Promise<Object>} Benutzer-Profil
|
||||
*/
|
||||
async getUserProfile(userId) {
|
||||
const { User, AuthInfo } = database.getModels();
|
||||
|
||||
const user = await User.findByPk(userId, {
|
||||
include: [{
|
||||
model: AuthInfo,
|
||||
as: 'authInfo',
|
||||
attributes: ['email']
|
||||
}]
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Benutzer nicht gefunden');
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
full_name: user.full_name,
|
||||
email: user.authInfo?.email,
|
||||
role: user.role,
|
||||
daily_hours: user.daily_hours,
|
||||
week_hours: user.week_hours,
|
||||
week_workdays: user.week_workdays
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuthService();
|
||||
|
||||
209
backend/src/services/OAuthService.js
Normal file
209
backend/src/services/OAuthService.js
Normal file
@@ -0,0 +1,209 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const database = require('../config/database');
|
||||
|
||||
/**
|
||||
* OAuth Service
|
||||
* Verwaltet OAuth/SSO-Logins (Google, GitHub, etc.)
|
||||
*/
|
||||
class OAuthService {
|
||||
constructor() {
|
||||
this.jwtSecret = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
this.jwtExpiration = process.env.JWT_EXPIRATION || '24h';
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth-Login/Registrierung
|
||||
* @param {Object} profile - OAuth-Provider-Profil
|
||||
* @param {string} provider - Provider-Name (google, github, etc.)
|
||||
* @returns {Promise<Object>} Token und Benutzer-Info
|
||||
*/
|
||||
async authenticateWithProvider(profile, provider) {
|
||||
const { User, AuthInfo, AuthIdentity, AuthToken } = database.getModels();
|
||||
|
||||
const providerId = profile.id;
|
||||
const email = profile.emails && profile.emails[0] ? profile.emails[0].value : null;
|
||||
const displayName = profile.displayName || profile.name || email;
|
||||
|
||||
if (!providerId) {
|
||||
throw new Error('OAuth Provider-ID fehlt');
|
||||
}
|
||||
|
||||
// Prüfen ob OAuth-Identity bereits existiert
|
||||
let authIdentity = await AuthIdentity.findOne({
|
||||
where: {
|
||||
provider,
|
||||
identity: providerId
|
||||
},
|
||||
include: [{
|
||||
model: database.getModels().AuthInfo,
|
||||
as: 'authInfo',
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'user'
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
let user, authInfo;
|
||||
|
||||
if (authIdentity && authIdentity.authInfo) {
|
||||
// Bestehender OAuth-Benutzer
|
||||
authInfo = authIdentity.authInfo;
|
||||
user = authInfo.user;
|
||||
} else {
|
||||
// Neuer OAuth-Benutzer oder Verknüpfung mit bestehendem Account
|
||||
|
||||
// Prüfen ob Benutzer mit dieser E-Mail bereits existiert
|
||||
if (email) {
|
||||
authInfo = await AuthInfo.findOne({
|
||||
where: { email },
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'user'
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
if (authInfo) {
|
||||
// Verknüpfe OAuth mit bestehendem Account
|
||||
user = authInfo.user;
|
||||
|
||||
authIdentity = await AuthIdentity.create({
|
||||
auth_info_id: authInfo.id,
|
||||
provider,
|
||||
identity: providerId,
|
||||
version: 0
|
||||
});
|
||||
} else {
|
||||
// Neuen Benutzer erstellen
|
||||
user = await User.create({
|
||||
full_name: displayName,
|
||||
role: 0,
|
||||
daily_hours: 8,
|
||||
week_hours: 40,
|
||||
week_workdays: 5,
|
||||
preferred_title_type: 0,
|
||||
version: 0,
|
||||
last_change: new Date()
|
||||
});
|
||||
|
||||
// Auth-Info erstellen (ohne Passwort für OAuth-only Accounts)
|
||||
authInfo = await AuthInfo.create({
|
||||
user_id: user.id,
|
||||
email: email || `${provider}_${providerId}@oauth.local`,
|
||||
password_hash: '', // Kein Passwort für OAuth-only
|
||||
password_method: 'oauth',
|
||||
password_salt: '',
|
||||
status: 1,
|
||||
failed_login_attempts: 0,
|
||||
email_token: '',
|
||||
email_token_role: 0,
|
||||
unverified_email: '',
|
||||
version: 0
|
||||
});
|
||||
|
||||
// OAuth-Identity erstellen
|
||||
authIdentity = await AuthIdentity.create({
|
||||
auth_info_id: authInfo.id,
|
||||
provider,
|
||||
identity: providerId,
|
||||
version: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// JWT Token generieren
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
email: authInfo.email,
|
||||
role: user.role,
|
||||
provider
|
||||
},
|
||||
this.jwtSecret,
|
||||
{ expiresIn: this.jwtExpiration }
|
||||
);
|
||||
|
||||
// Token in Datenbank speichern
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setHours(expiresAt.getHours() + 24);
|
||||
|
||||
await AuthToken.create({
|
||||
auth_info_id: authInfo.id,
|
||||
value: crypto.createHash('sha256').update(token).digest('hex'),
|
||||
expires: expiresAt,
|
||||
version: 0
|
||||
});
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: user.id,
|
||||
full_name: user.full_name,
|
||||
email: authInfo.email,
|
||||
role: user.role,
|
||||
provider
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth-Identity für Benutzer abrufen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @returns {Promise<Array>} Liste verknüpfter OAuth-Provider
|
||||
*/
|
||||
async getUserIdentities(userId) {
|
||||
const { AuthInfo, AuthIdentity } = database.getModels();
|
||||
|
||||
const authInfo = await AuthInfo.findOne({
|
||||
where: { user_id: userId },
|
||||
include: [{
|
||||
model: AuthIdentity,
|
||||
as: 'identities'
|
||||
}]
|
||||
});
|
||||
|
||||
if (!authInfo || !authInfo.identities) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return authInfo.identities.map(identity => ({
|
||||
provider: identity.provider,
|
||||
identity: identity.identity,
|
||||
id: identity.id
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth-Identity entfernen
|
||||
* @param {number} userId - Benutzer-ID
|
||||
* @param {string} provider - Provider-Name
|
||||
* @returns {Promise<boolean>} Erfolg
|
||||
*/
|
||||
async unlinkProvider(userId, provider) {
|
||||
const { AuthInfo, AuthIdentity } = database.getModels();
|
||||
|
||||
const authInfo = await AuthInfo.findOne({
|
||||
where: { user_id: userId }
|
||||
});
|
||||
|
||||
if (!authInfo) {
|
||||
throw new Error('Benutzer nicht gefunden');
|
||||
}
|
||||
|
||||
const deleted = await AuthIdentity.destroy({
|
||||
where: {
|
||||
auth_info_id: authInfo.id,
|
||||
provider
|
||||
}
|
||||
});
|
||||
|
||||
return deleted > 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new OAuthService();
|
||||
|
||||
|
||||
|
||||
2380
backend/src/services/TimeEntryService.js
Normal file
2380
backend/src/services/TimeEntryService.js
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user