Upload via GUI (267 Dateien)

This commit is contained in:
2026-10-19 18:06:02 +00:00
parent 51656d6578
commit 4466c959f7
6 changed files with 125 additions and 23 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
</main>
</div>
<script src="/assets/js/richtext.js"></script>
<script src="/assets/js/richtext.js?v=<?= @filemtime(__DIR__ . '/../assets/js/richtext.js') ?: time() ?>"></script>
</body>
</html>
+2 -7
View File
@@ -24,15 +24,10 @@ if (file_exists(__DIR__ . '/../includes/push.php')) {
<meta name="push-key" content="<?= e($pushKey) ?>">
<?php endif; ?>
<link rel="stylesheet" href="/assets/css/style.css?v=<?= @filemtime(__DIR__ . "/../assets/css/style.css") ?: time() ?>">
<link rel="icon" href="/favicon.ico" sizes="any">
<link rel="icon" href="/assets/icons/icon-192.png" type="image/png">
<?= site_icon_links() ?>
<link rel="manifest" href="/manifest-staff.php">
<meta name="theme-color" content="#0f6f6a">
<meta name="apple-mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" href="/assets/icons/icon-192.png">
<?php $faviconLogo = get_setting('logo_path'); if ($faviconLogo): ?>
<link rel="icon" href="<?= e($faviconLogo) ?>">
<?php endif; ?>
<script>
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/staff/sw-staff.js', {scope: '/staff/'}).catch(function(){});
@@ -122,7 +117,7 @@ if ('serviceWorker' in navigator) {
<?php if (has_permission($staff, 'manage_roles')): ?><a href="/staff/roles.php" class="<?= $activePage==='roles'?'active':'' ?>">Rollen</a><?php endif; ?>
<span class="nav-group-label">System</span>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/security_check.php" class="<?= $activePage==='security_check'?'active':'' ?>">Sicherheitsprüfung</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/security_check.php" class="<?= $activePage==='security_check'?'active':'' ?>">Sicherheitsprüfung</a><?php endif; ?>
<?php if (has_permission($staff, 'view_audit_log')): ?><a href="/staff/audit_log.php" class="<?= $activePage==='audit_log'?'active':'' ?>">Admin-Protokoll</a><?php endif; ?>
<?php if ($staff['role'] === 'admin'): ?><a href="/staff/ticket_rules.php" class="<?= $activePage==='ticket_rules'?'active':'' ?>">Ticket-Regeln</a><?php endif; ?>
<?php if (has_permission($staff, 'manage_escalations')): ?><a href="/staff/escalation_rules.php" class="<?= $activePage==='escalation_rules'?'active':'' ?>">Eskalationsregeln</a><?php endif; ?>
+1
View File
@@ -87,6 +87,7 @@ $siteName = get_setting('display_name', config()['site']['name'] ?? 'DeskHub');
@page { margin: 15mm; }
}
</style>
<?= site_icon_links() ?>
</head>
<body>
+5 -3
View File
@@ -31,6 +31,7 @@ $actionLabels = [
'assign_staff' => 'Mitarbeiter zuweisen',
'add_tag' => 'Tag hinzufügen',
'auto_reply' => 'Auto-Antwort senden (Textbaustein)',
'close' => 'Ticket schließen (wird angelegt, aber sofort geschlossen)',
'reject' => 'Ticket verwerfen (z.B. Spam)',
];
@@ -80,10 +81,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
'assign_staff' => $actStaff[$i] ?? '',
'add_tag' => trim($actTag[$i] ?? ''),
'auto_reply' => $actCanned[$i] ?? '',
'reject' => '1',
'reject', 'close' => '1',
default => '',
};
if ($t !== 'reject' && $value === '') continue;
if (!in_array($t, ['reject', 'close'], true) && $value === '') continue;
$actionsToSave[] = ['action_type' => $t, 'action_value' => $value];
}
if (!$actionsToSave) $errors[] = 'Bitte mindestens eine Aktion festlegen.';
@@ -213,6 +214,7 @@ function render_action_value_selects(array $departments, array $priorities, arra
<?php else: ?>
<span class="rule-val rule-val-auto_reply muted" style="<?= $sel('auto_reply') === '' ? '' : 'display:none' ?>; align-self:center; font-size:.85rem">Noch keine Textbausteine angelegt – <a href="/staff/canned_responses.php">jetzt erstellen</a>.</span>
<?php endif; ?>
<span class="rule-val rule-val-close muted" style="<?= $sel('close') === '' ? '' : 'display:none' ?>; align-self:center; font-size:.85rem">Das Ticket wird angelegt und gleich geschlossen. Es gibt keine Benachrichtigung an Mitarbeiter oder Kunden.</span>
<span class="rule-val rule-val-reject muted" style="<?= $sel('reject') === '' ? '' : 'display:none' ?>; align-self:center; font-size:.85rem">Keine weitere Einstellung nötig.</span>
<?php
}
@@ -258,7 +260,7 @@ function render_action_value_selects(array $departments, array $priorities, arra
</td>
<td class="muted" style="font-size:.82rem">
<?php foreach ($r['actions'] as $a): ?>
<div><?= e($actionLabels[$a['action_type']] ?? $a['action_type']) ?><?= $a['action_type']!=='reject' ? ': ' . e($a['action_value']) : '' ?></div>
<div><?= e($actionLabels[$a['action_type']] ?? $a['action_type']) ?><?= !in_array($a['action_type'], ['reject', 'close'], true) ? ': ' . e($a['action_value']) : '' ?></div>
<?php endforeach; ?>
</td>
<td>
+53 -9
View File
@@ -84,7 +84,10 @@ $departments = departments_for_select($pdo, [(int) $ticket['department_id']]);
$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
$agents = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
$teams = $pdo->query('SELECT id, name FROM teams WHERE is_active = 1 ORDER BY name')->fetchAll();
$cannedResponses = $pdo->query('SELECT id, title, body FROM canned_responses ORDER BY title')->fetchAll();
// Nur Vorlagen dieser Abteilung und solche, die für alle Abteilungen gelten (keine Abteilung zugeordnet)
$cannedStmt = $pdo->prepare('SELECT id, title, body, department_id FROM canned_responses WHERE department_id IS NULL OR department_id = ? ORDER BY title');
$cannedStmt->execute([(int) $ticket['department_id']]);
$cannedResponses = $cannedStmt->fetchAll();
$helpTopicName = null;
if (!empty($ticket['help_topic_id'])) {
@@ -252,12 +255,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
log_activity($pdo, $ticket['id'], $staff['id'], 'Automatisch zugewiesen (erste Antwort)');
}
$signature = trim($staff['signature'] ?? '');
if ($signature === '') {
$deptSigStmt = $pdo->prepare('SELECT signature FROM departments WHERE id = ?');
$deptSigStmt->execute([$ticket['department_id']]);
$signature = (string) $deptSigStmt->fetchColumn();
}
// Signatur laut Auswahl im Antwortformular (eigene, Abteilung oder keine); ohne Angabe wie bisher
$signature = resolve_reply_signature($pdo, $staff, (int) $ticket['department_id'], isset($_POST['signature_choice']) ? (string) $_POST['signature_choice'] : null);
$ccEmails = array_column($collaborators, 'email');
notify_customer_new_reply($ticket, $message, $signature, $ccEmails);
save_mentions($pdo, $ticket['id'], $threadId, $staff['id'], $message, $ticket);
@@ -996,6 +995,14 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
<div class="card">
<h3>Antworten / Notiz</h3>
<?php
$sigOwn = trim((string) ($staff['signature'] ?? ''));
$sigDeptStmt = $pdo->prepare('SELECT signature FROM departments WHERE id = ?');
$sigDeptStmt->execute([$ticket['department_id']]);
$sigDept = trim((string) $sigDeptStmt->fetchColumn());
$sigDefault = $sigOwn !== '' ? 'own' : ($sigDept !== '' ? 'dept' : 'none');
$sigHtml = ['own' => $sigOwn !== '' ? sanitize_staff_html($sigOwn) : '', 'dept' => $sigDept !== '' ? sanitize_staff_html($sigDept) : '', 'none' => ''];
?>
<form method="post" enctype="multipart/form-data">
<?= csrf_field() ?>
<input type="hidden" name="action" value="reply" id="form-action">
@@ -1021,8 +1028,16 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
<div style="display:flex;gap:8px;align-items:center;margin-bottom:6px">
<select id="canned_picker" style="flex:1" onchange="applyCannedResponse(this)">
<option value="">– Vorlage wählen –</option>
<?php foreach ($cannedResponses as $cr): ?>
<option value="<?= $cr['id'] ?>" data-body="<?= e($cr['body']) ?>"><?= e($cr['title']) ?></option>
<?php
$cannedOwn = array_filter($cannedResponses, fn($cr) => !empty($cr['department_id']));
$cannedAll = array_filter($cannedResponses, fn($cr) => empty($cr['department_id']));
foreach ([['Abteilung „' . ($ticket['department_name'] ?? '') . '“', $cannedOwn], ['Für alle Abteilungen', $cannedAll]] as [$groupLabel, $groupItems]):
if (!$groupItems) continue; ?>
<optgroup label="<?= e($groupLabel) ?>">
<?php foreach ($groupItems as $cr): ?>
<option value="<?= $cr['id'] ?>" data-body="<?= e($cr['body']) ?>"><?= e($cr['title']) ?></option>
<?php endforeach; ?>
</optgroup>
<?php endforeach; ?>
</select>
<button type="button" id="canned-preview-btn" style="display:none;font-size:.78rem;padding:5px 10px" class="btn secondary" onclick="toggleCannedPreview()">Vorschau</button>
@@ -1030,6 +1045,35 @@ $sourceLabel = $sourceLabels[$ticket['source']] ?? $ticket['source'];
<div id="canned-preview" style="display:none;background:var(--bg-soft);border:1px solid var(--border);border-radius:6px;padding:12px;font-size:.84rem;line-height:1.7;margin-bottom:8px;max-height:200px;overflow-y:auto"></div>
<?php endif; ?>
<textarea name="message" id="message-field" data-richtext placeholder="Nachricht eingeben..." required></textarea>
<div id="reply-signature" style="margin:14px 0">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:6px">
<label for="signature_choice" style="margin:0">Signatur</label>
<select name="signature_choice" id="signature_choice" style="width:auto;min-width:260px">
<option value="own" <?= $sigDefault === 'own' ? 'selected' : '' ?> <?= $sigOwn === '' ? 'disabled' : '' ?>>Meine Signatur<?= $sigOwn === '' ? ' (nicht hinterlegt)' : '' ?></option>
<option value="dept" <?= $sigDefault === 'dept' ? 'selected' : '' ?> <?= $sigDept === '' ? 'disabled' : '' ?>>Abteilung „<?= e($ticket['department_name']) ?>“<?= $sigDept === '' ? ' (nicht hinterlegt)' : '' ?></option>
<option value="none" <?= $sigDefault === 'none' ? 'selected' : '' ?>>Keine Signatur</option>
</select>
<a href="/staff/profile.php?tab=signature" class="muted" style="font-size:.78rem">Meine Signatur bearbeiten</a>
</div>
<div id="signature-preview" style="border:1px dashed var(--border);border-radius:8px;padding:12px 14px;font-size:.88rem;line-height:1.6;color:var(--muted);background:var(--bg-soft,transparent);min-height:20px"></div>
<div class="muted" style="font-size:.76rem;margin-top:4px">Wird nur an öffentliche Antworten angehängt, nicht an interne Notizen.</div>
</div>
<script>
(function () {
var sigs = <?= json_encode($sigHtml, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP) ?>;
var sel = document.getElementById('signature_choice');
var box = document.getElementById('signature-preview');
function show() {
var html = sigs[sel.value] || '';
if (html) { box.innerHTML = html; box.style.opacity = '1'; }
else { box.textContent = sel.value === 'none' ? 'Es wird keine Signatur angehängt.' : 'Keine Signatur hinterlegt.'; box.style.opacity = '.7'; }
}
sel.addEventListener('change', show);
show();
})();
</script>
<div class="field-row">
<div>
<label for="attachments">Anhänge (optional, mehrere möglich)</label>
+63 -3
View File
@@ -133,6 +133,25 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
$action = $_POST['action'] ?? '';
// Zugangstoken für den Update-Server speichern oder entfernen (nur Admins erreichen diese Seite)
if ($action === 'save_update_token') {
$newToken = trim((string) ($_POST['update_token'] ?? ''));
if ($newToken === '' || !preg_match('/^[A-Za-z0-9_\-.]{20,200}$/', $newToken)) {
$errors[] = 'Das Token sieht ungültig aus (erwartet: 20 bis 200 Zeichen aus Buchstaben, Ziffern, _ - .). Bitte genau so einfügen, wie Gitea es anzeigt.';
} else {
set_setting('update_token', encrypt_secret($newToken));
audit_log($staff['id'], 'Zugangstoken für den Update-Server gespeichert');
flash('success', 'Zugangstoken gespeichert. Die Release-Liste wird jetzt damit geladen.');
redirect('/staff/update.php');
}
}
if ($action === 'remove_update_token') {
set_setting('update_token', '');
audit_log($staff['id'], 'Zugangstoken für den Update-Server entfernt');
flash('success', 'Zugangstoken entfernt.');
redirect('/staff/update.php');
}
// Manueller Upload
if ($action === 'upload_update') {
$f = $_FILES['update_zip'] ?? [];
@@ -259,7 +278,9 @@ if ($code === 200) {
usort($all, fn($a, $b) => version_compare($b['tag_name'], $a['tag_name']));
$releases = $all;
} else {
$relErr = "API HTTP $code" . (update_http_headers() === [] ? ' (Ist das Repository privat? Dann in config/config.php unter update → token ein Lese-Token eintragen.)' : '');
$relErr = "API HTTP $code" . (update_http_headers() === []
? ($code === 404 || $code === 401 || $code === 403 ? ' – das Repository ist vermutlich privat. Unten unter „Zugang zum Update-Server“ ein Lese-Token eintragen.' : '')
: ($code === 404 || $code === 401 || $code === 403 ? ' – das eingetragene Token wird nicht akzeptiert (falsch, abgelaufen oder ohne Leserecht).' : ''));
}
$signedOnly = update_signature_required();
// Version frisch aus Datei lesen
@@ -284,6 +305,10 @@ require __DIR__ . '/_header.php';
<div class="flash error"><?= htmlspecialchars($e) ?></div>
<?php endforeach; ?>
<?php if ($relErr): ?>
<div class="flash warning" style="margin-bottom:16px">Die Liste der Versionen konnte nicht geladen werden: <?= htmlspecialchars($relErr) ?></div>
<?php endif; ?>
<?php if ($hasUpdate): ?>
<div style="background:#e0f2f1;border:1px solid #0f6f6a;border-radius:10px;padding:16px 20px;margin-bottom:20px;display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap">
<div>
@@ -365,12 +390,47 @@ require __DIR__ . '/_header.php';
</div>
<?php endif; ?>
<?php [$__tokenValue, $__tokenSource] = update_token_info(); ?>
<div class="card" style="margin-bottom:20px">
<h3>Zugang zum Update-Server</h3>
<p style="color:var(--muted);font-size:.86rem;margin-bottom:12px">
Nur nötig, wenn das Repository mit den Releases <strong>privat</strong> ist. Gitea meldet dann beim Laden der Versionen „404“. Ein Token mit <strong>Leserecht</strong> genügt
(in Gitea: Einstellungen → Anwendungen → Zugriffstoken erzeugen, Berechtigung „repository: Lesen“). Es wird verschlüsselt gespeichert.
</p>
<?php if ($__tokenSource === 'config'): ?>
<p style="margin-bottom:10px"><strong style="color:#15803d">✓ Token aus config/config.php wird verwendet.</strong> Es hat Vorrang vor einem hier gespeicherten.</p>
<?php elseif ($__tokenSource === 'einstellungen'): ?>
<p style="margin-bottom:10px"><strong style="color:#15803d">✓ Token gespeichert.</strong> Zum Ersetzen unten ein neues eintragen.</p>
<?php else: ?>
<p style="margin-bottom:10px;color:var(--muted)">Kein Token eingetragen (öffentlicher Zugriff).</p>
<?php endif; ?>
<form method="post" style="display:flex;gap:10px;flex-wrap:wrap;align-items:flex-end">
<?= csrf_field() ?>
<input type="hidden" name="action" value="save_update_token">
<div style="flex:1;min-width:260px">
<label for="update_token" style="margin-top:0">Zugriffstoken</label>
<input type="password" id="update_token" name="update_token" autocomplete="off" placeholder="<?= $__tokenSource === 'einstellungen' ? '•••••••• (gespeichert)' : 'Token aus Gitea einfügen' ?>" required>
</div>
<button type="submit" class="btn">Speichern</button>
</form>
<?php if ($__tokenSource === 'einstellungen'): ?>
<form method="post" style="margin-top:10px" onsubmit="return confirm('Gespeichertes Token entfernen?')">
<?= csrf_field() ?><input type="hidden" name="action" value="remove_update_token">
<button type="submit" class="btn secondary">Token entfernen</button>
</form>
<?php endif; ?>
<details style="margin-top:12px;font-size:.82rem;color:var(--muted)"><summary>Alternative: in config/config.php eintragen</summary>
<p style="margin:8px 0 4px">In der Datei <code>config/config.php</code> (Hauptordner von DeskHub, im Ordner <code>config</code>) als eigener Eintrag im großen <code>return [ … ];</code>, zum Beispiel direkt unter <code>'app_key'</code>:</p>
<pre style="background:var(--bg-soft,#f4f6f9);padding:10px;border-radius:6px;overflow:auto">'update' => ['token' =&gt; 'HIER_DAS_TOKEN'],</pre>
</details>
</div>
<div class="card">
<h3>Hinweise</h3>
<ul style="padding-left:20px;line-height:2;font-size:.88rem;color:var(--muted)">
<li><strong style="color:var(--ink)">config/config.php</strong> und <strong style="color:var(--ink)">database/deskhub.sqlite</strong> werden niemals überschrieben</li>
<?php if ($signedOnly): ?><li><strong style="color:var(--ink)">Nur signierte Releases:</strong> Updates werden nur installiert, wenn sie mit dem Schlüssel des Herausgebers signiert sind. Ein manipuliertes Paket wird abgelehnt. (Ein manuell hochgeladenes ZIP wird nicht geprüft: Laden Sie nur Pakete aus vertrauenswürdiger Quelle hoch.)</li><?php endif; ?>
<?php if ($relErr): ?><li style="color:#b45309">Release-Liste konnte nicht geladen werden: <?= htmlspecialchars($relErr) ?></li><?php endif; ?>
<?php if ($signedOnly): ?><li><strong style="color:var(--ink)">Nur signierte Releases:</strong> Updates werden nur installiert, wenn sie mit dem Schlüssel des Herausgebers signiert sind. Ein manipuliertes Paket wird abgelehnt. (Ein manuell hochgeladenes ZIP wird nicht geprüft: Laden Sie nur Pakete aus vertrauenswürdiger Quelle hoch.)</li><?php endif; ?>
<li>Nach dem Update Browser-Cache leeren <kbd>Strg+F5</kbd></li>
</ul>
</div>