Reorganize into monorepo layout, move mitarbeiter-app to legacy reference

Consolidates the previously separate omsorgapp and omsorgCore repos
(each had their own nested .git with GitHub history) plus the old
root-level website/mitarbeiter-app into a single monorepo, matching
the structure already documented in the root CLAUDE.md. Also moves
the PHP employee app aside as omsorgWeb/mitarbeiter-app-legacy/ to
serve as a template for a ground-up rewrite.

Fixes .gitignore in the same pass: the config-secrets/uploads/data
patterns were unanchored (relative to repo root, not depth-agnostic),
so they silently stopped matching once the app moved under omsorgWeb/.
Patterns are now **/-prefixed and cover both mitarbeiter-app and
mitarbeiter-app-legacy, keeping DB/SMTP credentials and uploaded
employee documents out of version control.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix Kemmler
2026-08-07 14:21:37 +02:00
co-authored by Claude Sonnet 5
parent 8beb0fcf52
commit b6c1389c55
355 changed files with 24348 additions and 361 deletions
+73
View File
@@ -0,0 +1,73 @@
const { request } = require('./httpClient.cjs');
// Kapselt /api/auth/* von omsorgCore. Kennt die Feldnamen der LoginResponse
// (camelCase, da ASP.NET Core standardmäßig camelCase-JSON ausgibt).
function toTokenPair(data) {
return {
accessToken: data.accessToken,
refreshToken: data.refreshToken,
expiresAt: data.expiresAt,
mustChangePassword: !!data.mustChangePassword
};
}
async function login(username, password) {
const result = await request('POST', '/api/auth/login', { body: { username, password } });
if (!result.ok) {
return { ok: false, status: result.status, error: result.error || result.data?.error };
}
return { ok: true, ...toTokenPair(result.data) };
}
async function refresh(refreshToken) {
const result = await request('POST', '/api/auth/refresh', { body: { refreshToken } });
if (!result.ok) return { ok: false, status: result.status };
return { ok: true, ...toTokenPair(result.data) };
}
async function logout(refreshToken) {
const result = await request('POST', '/api/auth/logout', { body: { refreshToken } });
return { ok: result.ok, status: result.status };
}
async function me(accessToken) {
const result = await request('GET', '/api/auth/me', { accessToken });
if (!result.ok) return { ok: false, status: result.status };
return { ok: true, username: result.data.username, role: result.data.role, permissions: result.data.permissions };
}
async function requestPasswordReset(username) {
const result = await request('POST', '/api/auth/forgot-password/request', { body: { username } });
if (!result.ok) return { ok: false, status: result.status };
return { ok: true, status: result.data.status };
}
async function verifyPasswordResetCode(username, pin) {
const result = await request('POST', '/api/auth/forgot-password/verify', { body: { username, pin } });
if (!result.ok) return { ok: false, status: result.status, error: result.data?.error };
return { ok: true, resetToken: result.data.resetToken };
}
async function resetPassword(resetToken, newPassword) {
const result = await request('POST', '/api/auth/forgot-password/reset', { body: { resetToken, newPassword } });
return { ok: result.ok, status: result.status };
}
async function changePassword(accessToken, currentPassword, newPassword) {
const result = await request('POST', '/api/auth/change-password', {
body: { currentPassword, newPassword },
accessToken
});
return { ok: result.ok, status: result.status, data: result.data };
}
module.exports = {
login,
refresh,
logout,
me,
requestPasswordReset,
verifyPasswordResetCode,
resetPassword,
changePassword
};
+6
View File
@@ -0,0 +1,6 @@
// Einzige Stelle im Projekt, die die Adresse von omsorgCore kennt.
// Default passt zum lokalen `dotnet run --project src/OmsorgCore.Api` (http-Profil, Port 5245),
// überschreibbar per Umgebungsvariable für andere Umgebungen (Produktion, anderer Rechner, ...).
module.exports = {
API_BASE: process.env.OMSORG_CORE_URL || 'http://localhost:5245'
};
@@ -0,0 +1,28 @@
const { request } = require('./httpClient.cjs');
// Kapselt /api/employees von omsorgCore (EmployeesController). Feldnamen sind
// camelCase, da ASP.NET Core standardmäßig camelCase-JSON ausgibt.
async function listEmployees(accessToken, { search, status, employmentType, page = 1, pageSize = 20 } = {}) {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (status) params.set('status', status);
if (employmentType) params.set('employmentType', employmentType);
params.set('page', page);
params.set('pageSize', pageSize);
return request('GET', `/api/employees?${params.toString()}`, { accessToken });
}
async function createEmployee(accessToken, payload) {
return request('POST', '/api/employees', { body: payload, accessToken });
}
async function getEmployee(accessToken, id) {
return request('GET', `/api/employees/${id}`, { accessToken });
}
async function updateEmployee(accessToken, id, payload) {
return request('PUT', `/api/employees/${id}`, { body: payload, accessToken });
}
module.exports = { listEmployees, createEmployee, getEmployee, updateEmployee };
+34
View File
@@ -0,0 +1,34 @@
const { API_BASE } = require('./config.cjs');
// Einzige Stelle, die fetch/API_BASE/Authorization-Header kennt. Kennt keine konkreten
// Endpunkte - die kommen aus den <kategorie>Client.cjs-Dateien (z.B. authClient.cjs).
async function request(method, path, { body, accessToken } = {}) {
const headers = { 'Content-Type': 'application/json' };
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
let response;
try {
response = await fetch(`${API_BASE}${path}`, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined
});
} catch (err) {
return { ok: false, status: 0, data: null, error: err.message };
}
if (response.status === 204) {
return { ok: true, status: 204, data: null };
}
let data = null;
try {
data = await response.json();
} catch {
data = null;
}
return { ok: response.ok, status: response.status, data };
}
module.exports = { request };
@@ -0,0 +1,12 @@
const { request } = require('./httpClient.cjs');
// Kapselt /api/users von omsorgCore (UsersController).
async function listUsers(accessToken) {
return request('GET', '/api/users', { accessToken });
}
async function createUser(accessToken, payload) {
return request('POST', '/api/users', { body: payload, accessToken });
}
module.exports = { listUsers, createUser };
+296
View File
@@ -0,0 +1,296 @@
const { app, BrowserWindow, ipcMain, shell, 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 usersClient = require('./backend/usersClient.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 ensureDir(dir) { if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); }
function appRoot() {
const base = path.join(app.getPath('documents'), 'Omsorg Business Controls Pro');
ensureDir(base);
ensureDir(path.join(base, 'database'));
ensureDir(path.join(base, 'documents'));
ensureDir(path.join(base, 'documents', 'employees'));
ensureDir(path.join(base, 'documents', 'customers'));
ensureDir(path.join(base, 'backups'));
return base;
}
function dbPath() { return path.join(appRoot(), 'database', 'omsorg-local-db.json'); }
function defaultData() {
return {
version: '0.1.1',
createdAt: new Date().toISOString(),
users: [
{ id:'u1', name:'Sabina Dautovic', role:'Geschäftsführung' },
{ id:'u2', name:'Malik Neumann', role:'Geschäftsführung' },
{ id:'u3', name:'Sabrina Berggötz', role:'Disposition' }
],
employees: [
{ id:'e1', name:'Dirk Artmann', qualification:'3-jährige Pflegefachkraft', status:'Aktiv' },
{ id:'e2', name:'Carina Vetter', qualification:'3-jährige Pflegefachkraft', status:'Aktiv' },
{ id:'e3', name:'Dilara Anders', qualification:'1-jährige Pflegekraft', status:'Aktiv' },
{ id:'e4', name:'Pascal Galliot', qualification:'1-jährige Pflegekraft', status:'Aktiv' }
],
customers: [],
assignments: [],
notes: []
};
}
function readDb() {
const file = dbPath();
if (!fs.existsSync(file)) fs.writeFileSync(file, JSON.stringify(defaultData(), null, 2));
return JSON.parse(fs.readFileSync(file, 'utf-8'));
}
function writeDb(data) { fs.writeFileSync(dbPath(), JSON.stringify(data, null, 2)); return data; }
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 () => {
appRoot();
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('db:get', () => readDb());
ipcMain.handle('db:set', (_event, data) => writeDb(data));
ipcMain.handle('app:paths', () => ({ root: appRoot(), db: dbPath() }));
ipcMain.handle('app:openRoot', () => shell.openPath(appRoot()));
ipcMain.handle('backup:create', () => {
const root = appRoot();
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupFile = path.join(root, 'backups', `backup-${stamp}.json`);
fs.copyFileSync(dbPath(), backupFile);
return backupFile;
});
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);
return { success: result.ok };
});
// 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) {
return { success: false, error: result.status === 401 ? 'invalid_current_password' : 'unknown' };
}
clearSession();
return { success: true };
});
// 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('users:list', () => withAuthRetry((accessToken) => usersClient.listUsers(accessToken)));
ipcMain.handle('users:create', (_event, payload) =>
withAuthRetry((accessToken) => usersClient.createUser(accessToken, payload))
);
+36
View File
@@ -0,0 +1,36 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('omsorg', {
getDb: () => ipcRenderer.invoke('db:get'),
setDb: (data) => ipcRenderer.invoke('db:set', data),
paths: () => ipcRenderer.invoke('app:paths'),
openRoot: () => ipcRenderer.invoke('app:openRoot'),
createBackup: () => ipcRenderer.invoke('backup:create'),
auth: {
login: (username, password) => ipcRenderer.invoke('auth:login', { username, password }),
logout: () => ipcRenderer.invoke('auth:logout'),
getSession: () => ipcRenderer.invoke('auth:getSession'),
onSessionChanged: (callback) => {
const listener = (_event, session) => callback(session);
ipcRenderer.on('auth:sessionChanged', listener);
return () => ipcRenderer.removeListener('auth:sessionChanged', listener);
},
requestPasswordReset: (username) => ipcRenderer.invoke('auth:requestPasswordReset', { username }),
verifyPasswordResetCode: (username, pin) => ipcRenderer.invoke('auth:verifyPasswordResetCode', { username, pin }),
resetPassword: (resetToken, newPassword) => ipcRenderer.invoke('auth:resetPassword', { resetToken, newPassword }),
changePassword: (currentPassword, newPassword) =>
ipcRenderer.invoke('auth:changePassword', { currentPassword, newPassword })
},
api: {
get: (path) => ipcRenderer.invoke('api:get', path),
post: (path, body) => ipcRenderer.invoke('api:post', path, body)
},
employees: {
list: (params) => ipcRenderer.invoke('employees:list', params),
create: (payload) => ipcRenderer.invoke('employees:create', payload),
update: (id, payload) => ipcRenderer.invoke('employees:update', id, payload)
},
users: {
list: () => ipcRenderer.invoke('users:list'),
create: (payload) => ipcRenderer.invoke('users:create', payload)
}
});