Extends omsorgCore with full CRUD for Facility/Contract/Order plus configurable value lists and an audit trail, and wires the omsorgapp frontend up to the new facilities, settings, and audit-log modules; includes a sidebar active-nav-item highlight. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
341 lines
13 KiB
JavaScript
341 lines
13 KiB
JavaScript
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 <kategorie>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 <kategorie>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))
|
|
);
|