Migrate omsorgapp to browser SPA, add Docker/CI build setup
Docker-Images bauen und veröffentlichen / build (, omsorgCore/Dockerfile, omsorgcore) (push) Successful in 3s
Docker-Images bauen und veröffentlichen / build (, omsorgWeb/Dockerfile, omsorgweb) (push) Failing after 4s
Docker-Images bauen und veröffentlichen / build (VITE_OMSORG_CORE_URL=${{ vars.OMSORG_CORE_PUBLIC_URL }}, omsorgapp/Dockerfile, omsorgapp) (push) Successful in 3s

- omsorgapp: drop Electron, run as a plain Vite/React browser app; refresh
  token moves to an HttpOnly cookie (omsorgCore), CORS added for the new
  browser origin, document download/preview switched to Blob-based browser
  APIs.
- Add Dockerfiles for omsorgCore, omsorgapp, and omsorgWeb, a docker-compose.yml
  wiring Postgres/MySQL/all three apps together, and a Gitea Actions workflow
  that builds and pushes images to the repo's container registry on push to
  main and on version tags.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Felix Kemmler
2026-08-10 17:42:45 +02:00
co-authored by Claude Sonnet 5
parent e9e96a57dc
commit 598dfcd38a
461 changed files with 59753 additions and 2859 deletions
@@ -20,10 +20,21 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if ($username === '') {
$error = 'Bitte Benutzername eingeben.';
} else {
omsorgcore_forgot_password_request(_omsorgcore_config(), $username);
// Bewusst immer weiter zu Schritt 2, unabhängig vom Ergebnis - kein Rückschluss darauf,
// ob der Username existiert (siehe PasswordResetService.RequestResetAsync in omsorgCore).
$step = 'verify';
$result = omsorgcore_forgot_password_request(_omsorgcore_config(), $username);
$status = $result['ok'] ? ($result['data']['status'] ?? null) : null;
if ($status === 'email_unavailable') {
// Einzige Ausnahme vom "immer weiter"-Prinzip unten: der Code wurde zwar angelegt,
// aber die E-Mail kam nachweislich nicht raus (z.B. SMTP down) - kein Sinn, den Nutzer
// auf eine Code-Eingabe warten zu lassen, die nie ankommt. Kein zusätzliches
// Enumeration-Risiko: "sent" vs. "cannot_reset" unterscheidet bereits, ob der Username
// existiert (siehe AuthController.ForgotPasswordRequest in omsorgCore).
$error = 'Der E-Mail-Versand ist gerade nicht verfügbar. Bitte später erneut versuchen oder einen Administrator kontaktieren.';
} else {
// Bewusst immer weiter zu Schritt 2, unabhängig vom Ergebnis - kein Rückschluss darauf,
// ob der Username existiert (siehe PasswordResetService.RequestResetAsync in omsorgCore).
$step = 'verify';
}
}
} elseif ($step === 'verify') {
$username = trim($_POST['username'] ?? '');
@@ -0,0 +1,292 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/layout.php';
require_login();
if (!has_permission('TimeEntries', 'View')) {
header('Location: dashboard.php');
exit;
}
$canCreate = has_permission('TimeEntries', 'Create');
$canEdit = has_permission('TimeEntries', 'Edit');
$error = '';
$success = false;
$editId = null;
$ordersResult = omsorgcore_orders_list(_omsorgcore_config(), $_SESSION['omsorgcore_access_token']);
$orders = $ordersResult['ok'] ? ($ordersResult['data']['items'] ?? []) : [];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$mode = $_POST['mode'] ?? 'create';
if ($mode === 'submit') {
if (!$canEdit) {
$error = 'Keine Berechtigung, diese Zeiterfassung einzureichen.';
} else {
$timeEntryId = $_POST['time_entry_id'] ?? '';
$result = omsorgcore_time_entries_submit(_omsorgcore_config(), $_SESSION['omsorgcore_access_token'], $timeEntryId);
if ($result['ok']) {
header('Location: stundenerfassung.php?submitted=1');
exit;
}
$error = $result['status'] === 403
? 'Keine Berechtigung, diese Zeiterfassung einzureichen.'
: (is_string($result['data'] ?? null) ? $result['data'] : 'Zeiterfassung konnte nicht eingereicht werden.');
}
}
if ($mode !== 'submit') {
$payload = [
'orderId' => $_POST['order_id'] ?? '',
'date' => $_POST['date'] ?? '',
'start' => ($_POST['start'] ?? '') !== '' ? $_POST['start'] . ':00' : '',
'end' => ($_POST['end'] ?? '') !== '' ? $_POST['end'] . ':00' : '',
'breakDuration' => sprintf('%02d:%02d:00', intdiv((int)($_POST['break_minutes'] ?? 0), 60), (int)($_POST['break_minutes'] ?? 0) % 60),
'nightHours' => (float)($_POST['night_hours'] ?? 0),
'saturdayHours' => (float)($_POST['saturday_hours'] ?? 0),
'sundayHours' => (float)($_POST['sunday_hours'] ?? 0),
'holidayHours' => (float)($_POST['holiday_hours'] ?? 0),
];
if ($payload['orderId'] === '' || $payload['date'] === '' || $payload['start'] === '' || $payload['end'] === '') {
$error = 'Auftrag, Datum, Beginn und Ende sind erforderlich.';
$editId = $mode === 'edit' ? ($_POST['time_entry_id'] ?? null) : null;
} elseif ($mode === 'edit') {
if (!$canEdit) {
// Server lehnt das ohnehin über [RequirePermission(TimeEntries, Edit)] ab - dieser Zweig
// greift nur, wenn jemand das Formular manuell postet, obwohl es clientseitig gar nicht
// angezeigt wurde.
$error = 'Keine Berechtigung, diese Zeiterfassung zu bearbeiten.';
} else {
$timeEntryId = $_POST['time_entry_id'] ?? '';
$result = omsorgcore_time_entries_update(_omsorgcore_config(), $_SESSION['omsorgcore_access_token'], $timeEntryId, $payload);
if ($result['ok']) {
header('Location: stundenerfassung.php?updated=1');
exit;
}
$editId = $timeEntryId;
$error = $result['status'] === 403
? 'Keine Berechtigung, diese Zeiterfassung zu bearbeiten.'
: (is_string($result['data'] ?? null) ? $result['data'] : 'Zeiterfassung konnte nicht gespeichert werden.');
}
} elseif (!$canCreate) {
$error = 'Keine Berechtigung, eine Zeiterfassung anzulegen.';
} else {
$result = omsorgcore_time_entries_create(_omsorgcore_config(), $_SESSION['omsorgcore_access_token'], $payload);
if ($result['ok']) {
$success = true;
} elseif ($result['status'] === 403) {
$error = 'Keine Berechtigung, eine Zeiterfassung anzulegen.';
} else {
$error = is_string($result['data'] ?? null) ? $result['data'] : 'Zeiterfassung konnte nicht gespeichert werden.';
}
}
}
} elseif (isset($_GET['edit'])) {
$editId = $_GET['edit'];
}
$listResult = omsorgcore_time_entries_list(_omsorgcore_config(), $_SESSION['omsorgcore_access_token']);
$timeEntries = $listResult['ok'] ? ($listResult['data']['items'] ?? []) : [];
// Statuswerte mit einer Selbst-Einreichungs-Kante (requiresApproval=false, siehe
// TimeEntryService.GetSelfServiceTransitionAsync in omsorgCore) - nur für diese Einträge zeigt die
// Liste den "Einreichen"-Button, damit er nicht auch bei bereits eingereichten Einträgen (die zwar
// noch isEditableByOwner sind, aber keine ausgehende Selbst-Einreichungs-Kante mehr haben) erscheint.
$transitionsResult = omsorgcore_value_list_transitions(_omsorgcore_config(), $_SESSION['omsorgcore_access_token'], 'TimeEntryStatus');
$transitions = $transitionsResult['ok'] ? ($transitionsResult['data'] ?? []) : [];
$submittableStatusIds = array_column(array_filter($transitions, fn($t) => empty($t['requiresApproval'])), 'fromItemId');
$editTimeEntry = null;
if ($editId !== null) {
foreach ($timeEntries as $timeEntry) {
if ($timeEntry['id'] === $editId) {
$editTimeEntry = $timeEntry;
break;
}
}
// Fremder/ungültiger/nicht mehr bearbeitbarer Eintrag - Own-Scope-Filterung passiert serverseitig
// in omsorgcore_time_entries_list, hier nur zusätzlich: nicht (mehr) bearbeitbar zurück zu "Neuer Eintrag".
if ($editTimeEntry === null || empty($editTimeEntry['isEditableByOwner'])) {
$editId = null;
$editTimeEntry = null;
}
}
function order_label(array $order): string
{
return substr($order['id'], 0, 8) . ' (' . ($order['requiredQualification'] ?? 'Auftrag') . ')';
}
layout_start('Zeiterfassung Mitarbeiter-App', 'stundenerfassung');
?>
<h1 class="main-title">Zeiterfassung</h1>
<div class="split-layout">
<div class="glass" style="padding:24px">
<h2 style="font-family:'KindelSerif',Georgia,serif;font-size:1.3rem;margin:0 0 18px">Meine Einträge</h2>
<?php if (empty($timeEntries)): ?>
<p>Noch keine Zeiterfassung angelegt.</p>
<?php else: ?>
<?php foreach ($timeEntries as $timeEntry): ?>
<div style="padding:12px 0;border-bottom:1px solid rgba(0,0,0,.08)">
<b><?= e(substr($timeEntry['date'], 0, 10)) ?></b> — <?= e($timeEntry['statusName']) ?>
<div class="hint">
<?= e(substr($timeEntry['start'], 0, 5)) ?> <?= e(substr($timeEntry['end'], 0, 5)) ?> Uhr, <?= e($timeEntry['facilityName']) ?>
</div>
<?php if (!empty($timeEntry['adminNote'])): ?>
<div class="hint">Kommentar: <?= e($timeEntry['adminNote']) ?></div>
<?php endif; ?>
<div style="margin-top:6px;display:flex;gap:12px">
<?php if ($canEdit && !empty($timeEntry['isEditableByOwner'])): ?>
<a href="stundenerfassung.php?edit=<?= e($timeEntry['id']) ?>">Bearbeiten</a>
<?php endif; ?>
<?php if ($canEdit && in_array($timeEntry['statusId'], $submittableStatusIds, true)): ?>
<form method="post" style="display:inline">
<?= csrf_field() ?>
<input type="hidden" name="mode" value="submit">
<input type="hidden" name="time_entry_id" value="<?= e($timeEntry['id']) ?>">
<button type="submit" style="background:none;border:none;padding:0;color:inherit;text-decoration:underline;cursor:pointer;font:inherit">Einreichen</button>
</form>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<div class="glass" style="padding:24px">
<h2 style="font-family:'KindelSerif',Georgia,serif;font-size:1.3rem;margin:0 0 18px">
<?= $editTimeEntry ? 'Eintrag bearbeiten' : 'Neuer Eintrag' ?>
</h2>
<?php if ($success): ?>
<div class="hint" style="margin-bottom:16px">Eintrag wurde angelegt.</div>
<?php endif; ?>
<?php if (isset($_GET['updated'])): ?>
<div class="hint" style="margin-bottom:16px">Eintrag wurde aktualisiert.</div>
<?php endif; ?>
<?php if (isset($_GET['submitted'])): ?>
<div class="hint" style="margin-bottom:16px">Eintrag wurde eingereicht.</div>
<?php endif; ?>
<?php if ($error): ?>
<div class="error" style="margin-bottom:16px"><?= e($error) ?></div>
<?php endif; ?>
<?php if ($editTimeEntry): ?>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="mode" value="edit">
<input type="hidden" name="time_entry_id" value="<?= e($editTimeEntry['id']) ?>">
<label>
Auftrag
<select name="order_id" required>
<option value="">— auswählen —</option>
<?php foreach ($orders as $order): ?>
<option value="<?= e($order['id']) ?>" <?= $order['id'] === $editTimeEntry['orderId'] ? 'selected' : '' ?>><?= e(order_label($order)) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
Datum
<input type="date" name="date" value="<?= e(substr($editTimeEntry['date'], 0, 10)) ?>" required>
</label>
<label>
Beginn
<input type="time" name="start" value="<?= e(substr($editTimeEntry['start'], 0, 5)) ?>" required>
</label>
<label>
Ende
<input type="time" name="end" value="<?= e(substr($editTimeEntry['end'], 0, 5)) ?>" required>
</label>
<label>
Pause (Minuten)
<input type="number" min="0" name="break_minutes" value="<?= e((string)((int)substr($editTimeEntry['breakDuration'], 0, 2) * 60 + (int)substr($editTimeEntry['breakDuration'], 3, 2))) ?>">
</label>
<label>
Nachtstunden
<input type="number" min="0" step="0.25" name="night_hours" value="<?= e((string)$editTimeEntry['nightHours']) ?>">
</label>
<label>
Samstagsstunden
<input type="number" min="0" step="0.25" name="saturday_hours" value="<?= e((string)$editTimeEntry['saturdayHours']) ?>">
</label>
<label>
Sonntagsstunden
<input type="number" min="0" step="0.25" name="sunday_hours" value="<?= e((string)$editTimeEntry['sundayHours']) ?>">
</label>
<label>
Feiertagsstunden
<input type="number" min="0" step="0.25" name="holiday_hours" value="<?= e((string)$editTimeEntry['holidayHours']) ?>">
</label>
<button type="submit" class="btn" style="width:100%;margin-top:6px">Änderungen speichern</button>
</form>
<p class="hint" style="text-align:center;margin-top:14px">
<a href="stundenerfassung.php">Abbrechen</a>
</p>
<?php elseif ($canCreate): ?>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="mode" value="create">
<label>
Auftrag
<select name="order_id" required>
<option value="">— auswählen —</option>
<?php foreach ($orders as $order): ?>
<option value="<?= e($order['id']) ?>"><?= e(order_label($order)) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
Datum
<input type="date" name="date" required>
</label>
<label>
Beginn
<input type="time" name="start" required>
</label>
<label>
Ende
<input type="time" name="end" required>
</label>
<label>
Pause (Minuten)
<input type="number" min="0" name="break_minutes" value="0">
</label>
<label>
Nachtstunden
<input type="number" min="0" step="0.25" name="night_hours" value="0">
</label>
<label>
Samstagsstunden
<input type="number" min="0" step="0.25" name="saturday_hours" value="0">
</label>
<label>
Sonntagsstunden
<input type="number" min="0" step="0.25" name="sunday_hours" value="0">
</label>
<label>
Feiertagsstunden
<input type="number" min="0" step="0.25" name="holiday_hours" value="0">
</label>
<button type="submit" class="btn" style="width:100%;margin-top:6px">Eintrag anlegen</button>
</form>
<?php else: ?>
<p class="hint">Keine Berechtigung, eine Zeiterfassung anzulegen.</p>
<?php endif; ?>
</div>
</div>
<?php
layout_end();
@@ -0,0 +1,235 @@
<?php
require_once __DIR__ . '/../lib/auth.php';
require_once __DIR__ . '/../lib/layout.php';
require_login();
if (!has_permission('Absences', 'View')) {
header('Location: dashboard.php');
exit;
}
$canCreate = has_permission('Absences', 'Create');
$canEdit = has_permission('Absences', 'Edit');
$error = '';
$success = false;
$editId = null;
$typesResult = omsorgcore_value_list_items(_omsorgcore_config(), $_SESSION['omsorgcore_access_token'], 'AbsenceType');
$types = $typesResult['ok'] ? $typesResult['data'] : [];
// Nicht auf den Anzeigetext "Eingereicht" hartkodieren - der ist über die Status-Verwaltung in
// omsorgapp umbenennbar (siehe omsorgCore/CLAUDE.md, AbsenceService.GetInitialStatusValueAsync).
$statusesResult = omsorgcore_value_list_items(_omsorgcore_config(), $_SESSION['omsorgcore_access_token'], 'AbsenceStatus');
$statuses = $statusesResult['ok'] ? $statusesResult['data'] : [];
$initialStatus = null;
foreach ($statuses as $statusItem) {
if (!empty($statusItem['isInitial'])) {
$initialStatus = $statusItem['value'];
break;
}
}
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$mode = $_POST['mode'] ?? 'create';
$payload = [
'type' => $_POST['type'] ?? '',
'startDate' => $_POST['start_date'] ?? '',
'endDate' => $_POST['end_date'] ?? '',
'reason' => trim($_POST['reason'] ?? ''),
'substitute' => trim($_POST['substitute'] ?? ''),
'note' => trim($_POST['note'] ?? ''),
];
if ($payload['type'] === '' || $payload['startDate'] === '' || $payload['endDate'] === '') {
$error = 'Art, Beginn und Ende sind erforderlich.';
$editId = $mode === 'edit' ? ($_POST['absence_id'] ?? null) : null;
} elseif ($payload['endDate'] < $payload['startDate']) {
$error = 'Das Ende darf nicht vor dem Beginn liegen.';
$editId = $mode === 'edit' ? ($_POST['absence_id'] ?? null) : null;
} elseif ($mode === 'edit') {
if (!$canEdit) {
// Server lehnt das ohnehin über [RequirePermission(Absences, Edit)] ab - dieser Zweig
// greift nur, wenn jemand das Formular manuell postet, obwohl es clientseitig gar nicht
// angezeigt wurde.
$error = 'Keine Berechtigung, diesen Antrag zu bearbeiten.';
} else {
$absenceId = $_POST['absence_id'] ?? '';
$result = omsorgcore_absences_update(_omsorgcore_config(), $_SESSION['omsorgcore_access_token'], $absenceId, $payload);
if ($result['ok']) {
header('Location: urlaubsantrag.php?updated=1');
exit;
}
$editId = $absenceId;
$error = $result['status'] === 403
? 'Keine Berechtigung, diesen Antrag zu bearbeiten.'
: (is_string($result['data'] ?? null) ? $result['data'] : 'Antrag konnte nicht gespeichert werden.');
}
} elseif (!$canCreate) {
$error = 'Keine Berechtigung, einen Antrag zu stellen.';
} else {
$result = omsorgcore_absences_create(_omsorgcore_config(), $_SESSION['omsorgcore_access_token'], $payload);
if ($result['ok']) {
$success = true;
} elseif ($result['status'] === 403) {
$error = 'Keine Berechtigung, einen Antrag zu stellen.';
} else {
$error = is_string($result['data'] ?? null) ? $result['data'] : 'Antrag konnte nicht gespeichert werden.';
}
}
} elseif (isset($_GET['edit'])) {
$editId = $_GET['edit'];
}
$listResult = omsorgcore_absences_list(_omsorgcore_config(), $_SESSION['omsorgcore_access_token']);
$absences = $listResult['ok'] ? ($listResult['data']['items'] ?? []) : [];
$editAbsence = null;
if ($editId !== null) {
foreach ($absences as $absence) {
if ($absence['id'] === $editId) {
$editAbsence = $absence;
break;
}
}
// Fremder/ungültiger/schon entschiedener Antrag - Own-Scope-Filterung passiert serverseitig
// in omsorgcore_absences_list, hier nur zusätzlich: nicht (mehr) bearbeitbar zurück zu "Neuer Antrag".
if ($editAbsence === null || $editAbsence['status'] !== $initialStatus) {
$editId = null;
$editAbsence = null;
}
}
layout_start('Urlaub & Abwesenheit Mitarbeiter-App', 'urlaubsantrag');
?>
<h1 class="main-title">Urlaub &amp; Abwesenheit</h1>
<div class="split-layout">
<div class="glass" style="padding:24px">
<h2 style="font-family:'KindelSerif',Georgia,serif;font-size:1.3rem;margin:0 0 18px">Meine Anträge</h2>
<?php if (empty($absences)): ?>
<p>Noch keine Anträge eingereicht.</p>
<?php else: ?>
<?php foreach ($absences as $absence): ?>
<div style="padding:12px 0;border-bottom:1px solid rgba(0,0,0,.08)">
<b><?= e($absence['type']) ?></b> — <?= e($absence['status']) ?>
<div class="hint">
<?= e(substr($absence['startDate'], 0, 10)) ?> <?= e(substr($absence['endDate'], 0, 10)) ?>
</div>
<?php if (!empty($absence['adminNote'])): ?>
<div class="hint">Kommentar: <?= e($absence['adminNote']) ?></div>
<?php endif; ?>
<?php if ($canEdit && $absence['status'] === $initialStatus): ?>
<div style="margin-top:6px">
<a href="urlaubsantrag.php?edit=<?= e($absence['id']) ?>">Bearbeiten</a>
</div>
<?php endif; ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
<div class="glass" style="padding:24px">
<h2 style="font-family:'KindelSerif',Georgia,serif;font-size:1.3rem;margin:0 0 18px">
<?= $editAbsence ? 'Antrag bearbeiten' : 'Neuer Antrag' ?>
</h2>
<?php if ($success): ?>
<div class="hint" style="margin-bottom:16px">Antrag wurde eingereicht.</div>
<?php endif; ?>
<?php if (isset($_GET['updated'])): ?>
<div class="hint" style="margin-bottom:16px">Antrag wurde aktualisiert.</div>
<?php endif; ?>
<?php if ($error): ?>
<div class="error" style="margin-bottom:16px"><?= e($error) ?></div>
<?php endif; ?>
<?php if ($editAbsence): ?>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="mode" value="edit">
<input type="hidden" name="absence_id" value="<?= e($editAbsence['id']) ?>">
<label>
Art
<select name="type" required>
<option value="">— auswählen —</option>
<?php foreach ($types as $type): ?>
<option value="<?= e($type['value']) ?>" <?= $type['value'] === $editAbsence['type'] ? 'selected' : '' ?>><?= e($type['value']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
Beginn
<input type="date" name="start_date" value="<?= e(substr($editAbsence['startDate'], 0, 10)) ?>" required>
</label>
<label>
Ende
<input type="date" name="end_date" value="<?= e(substr($editAbsence['endDate'], 0, 10)) ?>" required>
</label>
<label>
Grund (optional)
<input type="text" name="reason" maxlength="500" value="<?= e($editAbsence['reason'] ?? '') ?>">
</label>
<label>
Vertretung (optional)
<input type="text" name="substitute" maxlength="200" value="<?= e($editAbsence['substitute'] ?? '') ?>">
</label>
<label>
Nachricht (optional)
<textarea name="note" maxlength="500"><?= e($editAbsence['note'] ?? '') ?></textarea>
</label>
<button type="submit" class="btn" style="width:100%;margin-top:6px">Änderungen speichern</button>
</form>
<p class="hint" style="text-align:center;margin-top:14px">
<a href="urlaubsantrag.php">Abbrechen</a>
</p>
<?php elseif ($canCreate): ?>
<form method="post">
<?= csrf_field() ?>
<input type="hidden" name="mode" value="create">
<label>
Art
<select name="type" required>
<option value="">— auswählen —</option>
<?php foreach ($types as $type): ?>
<option value="<?= e($type['value']) ?>"><?= e($type['value']) ?></option>
<?php endforeach; ?>
</select>
</label>
<label>
Beginn
<input type="date" name="start_date" required>
</label>
<label>
Ende
<input type="date" name="end_date" required>
</label>
<label>
Grund (optional)
<input type="text" name="reason" maxlength="500">
</label>
<label>
Vertretung (optional)
<input type="text" name="substitute" maxlength="200">
</label>
<label>
Nachricht (optional)
<textarea name="note" maxlength="500"></textarea>
</label>
<button type="submit" class="btn" style="width:100%;margin-top:6px">Antrag einreichen</button>
</form>
<?php else: ?>
<p class="hint">Keine Berechtigung, einen Antrag zu stellen.</p>
<?php endif; ?>
</div>
</div>
<?php
layout_end();