const { app, BrowserWindow, ipcMain, safeStorage } = require('electron'); const path = require('path'); const fs = require('fs'); const authClient = require('./backend/authClient.cjs'); const httpClient = require('./backend/httpClient.cjs'); const employeesClient = require('./backend/employeesClient.cjs'); const facilitiesClient = require('./backend/facilitiesClient.cjs'); const facilityContactsClient = require('./backend/facilityContactsClient.cjs'); const usersClient = require('./backend/usersClient.cjs'); const rolesClient = require('./backend/rolesClient.cjs'); const auditLogClient = require('./backend/auditLogClient.cjs'); const valueListsClient = require('./backend/valueListsClient.cjs'); app.commandLine.appendSwitch('lang', 'de'); let mainWindow; const isDev = !app.isPackaged; let session = { accessToken: null, expiresAt: null, user: null, mustChangePassword: false }; let refreshTimer = null; const REFRESH_BUFFER_MS = 2 * 60 * 1000; function sessionFilePath() { return path.join(app.getPath('userData'), 'session.enc'); } function saveRefreshToken(rawToken) { if (!safeStorage.isEncryptionAvailable()) return; fs.writeFileSync(sessionFilePath(), safeStorage.encryptString(rawToken)); } function loadRefreshToken() { const file = sessionFilePath(); if (!fs.existsSync(file) || !safeStorage.isEncryptionAvailable()) return null; try { return safeStorage.decryptString(fs.readFileSync(file)); } catch { return null; } } function clearRefreshToken() { const file = sessionFilePath(); if (fs.existsSync(file)) fs.unlinkSync(file); } // Nur für die Anzeige "angemeldet als ..." im Renderer - die Signaturprüfung passiert // serverseitig bei jedem authentifizierten Aufruf, hier wird nichts sicherheitsrelevantes entschieden. function decodeJwtClaims(token) { try { const payload = token.split('.')[1]; return JSON.parse(Buffer.from(payload, 'base64url').toString('utf-8')); } catch { return {}; } } function userFromAccessToken(accessToken) { const claims = decodeJwtClaims(accessToken); return { username: claims['name'] || claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name'] || null, role: claims['role'] || claims['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role'] || null }; } function broadcastSession() { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send('auth:sessionChanged', { isAuthenticated: !!session.accessToken, user: session.user, mustChangePassword: session.mustChangePassword }); } } function scheduleRefresh() { if (refreshTimer) clearTimeout(refreshTimer); if (!session.expiresAt) return; const delay = Math.max(new Date(session.expiresAt).getTime() - Date.now() - REFRESH_BUFFER_MS, 5000); refreshTimer = setTimeout(refreshSilently, delay); } function applySession(tokenPair) { session = { accessToken: tokenPair.accessToken, expiresAt: tokenPair.expiresAt, user: userFromAccessToken(tokenPair.accessToken), mustChangePassword: !!tokenPair.mustChangePassword }; saveRefreshToken(tokenPair.refreshToken); scheduleRefresh(); broadcastSession(); refreshPermissions(); } // Rechte kommen nicht aus dem JWT (das trägt nur den Rollennamen), sondern aus /api/auth/me - // läuft nach dem schnellen JWT-basierten Zwischenstand nach und broadcastet erneut, sobald da. async function refreshPermissions() { const accessToken = session.accessToken; if (!accessToken) return; const profile = await authClient.me(accessToken); if (!profile.ok || session.accessToken !== accessToken) return; session.user = { username: profile.username, role: profile.role, permissions: profile.permissions }; broadcastSession(); } function clearSession() { session = { accessToken: null, expiresAt: null, user: null, mustChangePassword: false }; clearRefreshToken(); if (refreshTimer) clearTimeout(refreshTimer); refreshTimer = null; broadcastSession(); } // Proaktiver Timer kurz vor Ablauf UND Fallback bei 401 (z.B. nach Standby) - siehe Plan. async function refreshSilently() { const rawRefreshToken = loadRefreshToken(); if (!rawRefreshToken) { clearSession(); return false; } const result = await authClient.refresh(rawRefreshToken); if (!result.ok) { clearSession(); return false; } applySession(result); return true; } async function bootstrapSession() { const rawRefreshToken = loadRefreshToken(); if (!rawRefreshToken) return; const result = await authClient.refresh(rawRefreshToken); if (result.ok) { applySession(result); } else { clearRefreshToken(); } } // Ruft callFn(accessToken) auf und wiederholt einmal nach Silent-Refresh bei 401 - // gemeinsame Grundlage für den generischen api:*-Proxy und die Client.cjs-Aufrufe. async function withAuthRetry(callFn) { if (!session.accessToken) return { ok: false, status: 401, data: null }; let result = await callFn(session.accessToken); if (result.status === 401 && (await refreshSilently())) { result = await callFn(session.accessToken); } return result; } function authorizedRequest(method, resourcePath, body) { return withAuthRetry((accessToken) => httpClient.request(method, resourcePath, { body, accessToken })); } function createWindow() { mainWindow = new BrowserWindow({ width: 1440, height: 920, minWidth: 1100, minHeight: 760, title: 'Omsorg Business Controls Pro', backgroundColor: '#08111f', webPreferences: { preload: path.join(__dirname, 'preload.cjs'), contextIsolation: true, nodeIntegration: false } }); if (isDev) mainWindow.loadURL('http://127.0.0.1:5173'); else mainWindow.loadFile(path.join(__dirname, '../dist/index.html')); } app.whenReady().then(async () => { createWindow(); await bootstrapSession(); broadcastSession(); }); app.on('window-all-closed', () => { if (process.platform !== 'darwin') app.quit(); }); app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow(); }); ipcMain.handle('auth:login', async (_event, { username, password }) => { const result = await authClient.login(username, password); if (!result.ok) { const error = result.status === 0 ? 'network_error' : result.status === 429 ? 'too_many_attempts' : 'invalid_credentials'; return { success: false, error }; } applySession(result); return { success: true, user: session.user, mustChangePassword: session.mustChangePassword }; }); ipcMain.handle('auth:logout', async () => { const rawRefreshToken = loadRefreshToken(); if (rawRefreshToken) { try { await authClient.logout(rawRefreshToken); } catch { /* best effort */ } } clearSession(); return { success: true }; }); ipcMain.handle('auth:getSession', () => ({ isAuthenticated: !!session.accessToken, user: session.user, mustChangePassword: session.mustChangePassword })); ipcMain.handle('auth:requestPasswordReset', async (_event, { username }) => { const result = await authClient.requestPasswordReset(username); if (!result.ok) return { success: false, error: 'network_error' }; return { success: true, status: result.status }; }); ipcMain.handle('auth:verifyPasswordResetCode', async (_event, { username, pin }) => { const result = await authClient.verifyPasswordResetCode(username, pin); if (!result.ok) return { success: false, error: result.error || 'invalid_or_expired' }; return { success: true, resetToken: result.resetToken }; }); ipcMain.handle('auth:resetPassword', async (_event, { resetToken, newPassword }) => { const result = await authClient.resetPassword(resetToken, newPassword); if (!result.ok) { const error = result.status === 400 ? 'password_too_short' : 'invalid_or_expired'; return { success: false, error, message: result.status === 400 && typeof result.data === 'string' ? result.data : undefined }; } return { success: true }; }); // Erfolgreicher Wechsel widerruft serverseitig alle Sessions (siehe UserService.ChangeOwnPasswordAsync) - // der aktuelle Access-Token ist danach tot, also Session lokal beenden statt weiterzumachen. ipcMain.handle('auth:changePassword', async (_event, { currentPassword, newPassword }) => { if (!session.accessToken) return { success: false, error: 'not_authenticated' }; const result = await authClient.changePassword(session.accessToken, currentPassword, newPassword); if (!result.ok) { const error = result.status === 401 ? 'invalid_current_password' : result.status === 400 ? 'password_too_short' : 'unknown'; return { success: false, error, message: result.status === 400 && typeof result.data === 'string' ? result.data : undefined }; } clearSession(); return { success: true }; }); ipcMain.handle('auth:getPasswordPolicy', async () => { const result = await authClient.getPasswordPolicy(); if (!result.ok) return { success: false }; return { success: true, minLength: result.minLength }; }); // Generischer Proxy für Ressourcen ohne eigene Client.cjs-Datei (siehe Plan). ipcMain.handle('api:get', (_event, resourcePath) => authorizedRequest('GET', resourcePath)); ipcMain.handle('api:post', (_event, resourcePath, body) => authorizedRequest('POST', resourcePath, body)); ipcMain.handle('employees:list', (_event, params) => withAuthRetry((accessToken) => employeesClient.listEmployees(accessToken, params))); ipcMain.handle('employees:create', (_event, payload) => withAuthRetry((accessToken) => employeesClient.createEmployee(accessToken, payload)) ); ipcMain.handle('employees:update', (_event, id, payload) => withAuthRetry((accessToken) => employeesClient.updateEmployee(accessToken, id, payload)) ); ipcMain.handle('facilities:list', (_event, params) => withAuthRetry((accessToken) => facilitiesClient.listFacilities(accessToken, params))); ipcMain.handle('facilities:create', (_event, payload) => withAuthRetry((accessToken) => facilitiesClient.createFacility(accessToken, payload)) ); ipcMain.handle('facilities:update', (_event, id, payload) => withAuthRetry((accessToken) => facilitiesClient.updateFacility(accessToken, id, payload)) ); ipcMain.handle('facilityContacts:list', (_event, facilityId) => withAuthRetry((accessToken) => facilityContactsClient.listFacilityContacts(accessToken, facilityId)) ); ipcMain.handle('facilityContacts:create', (_event, facilityId, payload) => withAuthRetry((accessToken) => facilityContactsClient.createFacilityContact(accessToken, facilityId, payload)) ); ipcMain.handle('facilityContacts:update', (_event, facilityId, id, payload) => withAuthRetry((accessToken) => facilityContactsClient.updateFacilityContact(accessToken, facilityId, id, payload)) ); ipcMain.handle('users:list', () => withAuthRetry((accessToken) => usersClient.listUsers(accessToken))); ipcMain.handle('users:create', (_event, payload) => withAuthRetry((accessToken) => usersClient.createUser(accessToken, payload)) ); ipcMain.handle('users:listPermissionOverrides', (_event, userId) => withAuthRetry((accessToken) => usersClient.listPermissionOverrides(accessToken, userId)) ); ipcMain.handle('users:addPermissionOverride', (_event, userId, payload) => withAuthRetry((accessToken) => usersClient.addPermissionOverride(accessToken, userId, payload)) ); ipcMain.handle('users:deletePermissionOverride', (_event, userId, overrideId) => withAuthRetry((accessToken) => usersClient.deletePermissionOverride(accessToken, userId, overrideId)) ); ipcMain.handle('roles:list', () => withAuthRetry((accessToken) => rolesClient.listRoles(accessToken))); ipcMain.handle('roles:get', (_event, roleId) => withAuthRetry((accessToken) => rolesClient.getRole(accessToken, roleId)) ); ipcMain.handle('roles:create', (_event, payload) => withAuthRetry((accessToken) => rolesClient.createRole(accessToken, payload)) ); ipcMain.handle('roles:updatePermissions', (_event, roleId, payload) => withAuthRetry((accessToken) => rolesClient.updateRolePermissions(accessToken, roleId, payload)) ); ipcMain.handle('auditLog:list', (_event, params) => withAuthRetry((accessToken) => auditLogClient.listAuditLog(accessToken, params)) ); ipcMain.handle('valueLists:list', () => withAuthRetry((accessToken) => valueListsClient.listValueLists(accessToken))); ipcMain.handle('valueLists:listItems', (_event, key) => withAuthRetry((accessToken) => valueListsClient.listItems(accessToken, key)) ); ipcMain.handle('valueLists:createItem', (_event, key, payload) => withAuthRetry((accessToken) => valueListsClient.createItem(accessToken, key, payload)) ); ipcMain.handle('valueLists:updateItem', (_event, key, id, payload) => withAuthRetry((accessToken) => valueListsClient.updateItem(accessToken, key, id, payload)) ); ipcMain.handle('valueLists:deleteItem', (_event, key, id) => withAuthRetry((accessToken) => valueListsClient.deleteItem(accessToken, key, id)) ); ipcMain.handle('valueLists:getUsages', (_event, key, id) => withAuthRetry((accessToken) => valueListsClient.getUsages(accessToken, key, id)) ); ipcMain.handle('valueLists:listTransitions', (_event, key) => withAuthRetry((accessToken) => valueListsClient.listTransitions(accessToken, key)) ); ipcMain.handle('valueLists:replaceTransitions', (_event, key, payload) => withAuthRetry((accessToken) => valueListsClient.replaceTransitions(accessToken, key, payload)) );