diff --git a/chrome-erweiterung/README.md b/chrome-erweiterung/README.md new file mode 100644 index 00000000..4af55353 --- /dev/null +++ b/chrome-erweiterung/README.md @@ -0,0 +1,62 @@ +# DeskHub für Chrome (Erweiterung) + +DeskHub in der Browserleiste: **Zähler und Benachrichtigungen** für neue Tickets, **Suche**, **Ticket anlegen** und Tickets mit einem Klick im Browser öffnen. Sie läuft in Chrome, Edge und Brave (Manifest V3) auf Windows, Mac und Linux und ist die einfachste Lösung für Mitarbeiter ohne Windows-Programm. + +## Funktionen + +- **Zähler am Symbol:** Jede Minute fragt die Erweiterung den Server nach Neuem (neue nicht zugewiesene Tickets und Aktualisierungen bei deinen Tickets). Die Zahl am Symbol zählt, was seit dem letzten Öffnen dazugekommen ist; ein Klick auf das Symbol setzt sie zurück. +- **Benachrichtigungen:** Bei Neuem meldet Chrome es (im Popup unten abschaltbar). +- **Übersicht:** Meine offenen, überfällige und nicht zugewiesene Tickets sowie die als Nächstes fälligen Tickets. +- **Suche:** In Betreff, Nummer, Name und Text, über alle Status. +- **Neu:** Ticket anlegen (Anfrager, Betreff, Beschreibung, Abteilung, Priorität, optional Kunden benachrichtigen). Das Recht „Ticket anlegen“ ist Voraussetzung, sonst fehlt der Reiter. +- **Rechtsklick auf Text oder Seite → „Als DeskHub-Ticket anlegen“:** Öffnet das Formular mit dem markierten Text und der Adresse der Seite. +- **Ticket im Browser öffnen:** Ein Klick auf ein Ticket öffnet es in der Weboberfläche. Dafür erzeugt der Server eine einmalige Anmelde-Adresse (wie in den Apps), du musst dich dort nicht erneut anmelden. + +## Installieren + +1. In Chrome `chrome://extensions` öffnen und **Entwicklermodus** einschalten. +2. **Entpackte Erweiterung laden** und den Ordner `chrome-erweiterung` wählen (oder das ZIP aus `release/` vorher entpacken). +3. Das DeskHub-Symbol in der Leiste anheften, anklicken und anmelden: Server-Adresse (zum Beispiel `support.example.de`), E-Mail und Passwort. Ist dein Konto mit einem Authenticator geschützt, fragt die Erweiterung danach den 6-stelligen Code. + +Chrome fragt dabei **einmal** nach der Erlaubnis, auf deinen DeskHub-Server zuzugreifen. Die Erlaubnis gilt nur für diese eine Adresse, nicht für andere Webseiten, und wird beim Abmelden wieder zurückgegeben. + +## Sicherheit und Datenschutz + +- Verbindung nur über **HTTPS** (`http` nur für `localhost` zum Testen). Die Erweiterung folgt keinen Weiterleitungen und sendet keine Cookies. +- Das Anmelde-Token liegt nur in `chrome.storage.local` dieser Erweiterung (nicht in `sync`, nicht im Seitencode). **Abmelden** sperrt das Token auf dem Server. +- Rechte: `storage`, `alarms`, `notifications`, `contextMenus`. Zugriff auf Webseiten gibt es erst nach deiner Zustimmung bei der Anmeldung, und nur für deinen Server. +- Die Oberfläche baut alles mit DOM-Aufrufen auf (kein `innerHTML` mit Serverdaten); die Inhaltsrichtlinie der Erweiterung erlaubt nur eigene Skripte und Verbindungen über HTTPS. +- Texte, die du anlegst, werden als Text gesendet (Sonderzeichen maskiert); der Server bereinigt sie zusätzlich. + +## Grenzen + +- Die Erweiterung fragt **jede Minute** ab (Chrome erlaubt für Erweiterungen nicht öfter). Ist Chrome geschlossen, gibt es keine Meldungen. Für Meldungen auch bei geschlossenem Browser nutzt die Android-App oder das Windows-Programm. +- Antworten auf Tickets, Zuweisen, Status ändern und Notizen gehen nicht in der Erweiterung: Dafür öffnet ein Klick das Ticket in der Weboberfläche. +- Die Oberfläche ist deutsch. +- Sie ist (noch) nicht im Chrome Web Store. Das ZIP lässt sich dort als nicht gelistete Erweiterung hochladen, dafür braucht es ein Entwicklerkonto bei Google. + +## Entwickeln und testen + +```bash +node --test test/core.test.mjs # Kern und Hintergrunddienst mit nachgebautem Browser und Server +``` + +Gegen einen **echten** DeskHub-Server (Testinstallation, nicht Produktion): + +```bash +DESKHUB_URL=http://127.0.0.1:8181 DESKHUB_EMAIL=… DESKHUB_PASSWORD=… node --test test/integration.test.mjs +``` + +Der Integrationstest meldet an, ruft Stammdaten, Übersicht und Suche ab, legt ein Ticket an, prüft die Aktivitätszählung, öffnet ein Ticket per einmaliger Adresse und meldet ab (danach muss das alte Token abgelehnt werden). Ohne `DESKHUB_URL` wird er übersprungen. + +Packen für die Weitergabe: `node pack.mjs` erzeugt `../release/deskhub-chrome-erweiterung-.zip` mit nur den Laufzeitdateien. + +## Aufbau + +| Datei | Aufgabe | +|---|---| +| `manifest.json` | Rechte, Hintergrunddienst, Inhaltsrichtlinie | +| `background.js` | Abfrage jede Minute, Zähler, Benachrichtigungen, Rechtsklick-Menü | +| `popup.html/js/css` | Oberfläche (Anmeldung, Übersicht, Suche, Neu) | +| `lib/core.js` | Serveradresse, Anmeldung, API-Aufrufe, Sicherheitsprüfungen | +| `test/` | Tests | diff --git a/chrome-erweiterung/background.js b/chrome-erweiterung/background.js new file mode 100644 index 00000000..45525173 --- /dev/null +++ b/chrome-erweiterung/background.js @@ -0,0 +1,80 @@ +// Hintergrunddienst: prüft jede Minute auf neue Tickets/Antworten, zeigt den Zähler am Symbol und Benachrichtigungen, +// bietet „Als DeskHub-Ticket anlegen“ im Rechtsklick-Menü an. +import { apiCall, getSession, getSettings, truncate } from './lib/core.js'; + +const ALARM = 'deskhub-poll'; +const MENU = 'deskhub-create'; + +async function setBadge(count, color = '#0f766e') { + await chrome.action.setBadgeBackgroundColor({ color }); + await chrome.action.setBadgeText({ text: count > 0 ? (count > 99 ? '99+' : String(count)) : '' }); +} + +async function ensureSetup() { + if (!(await chrome.alarms.get(ALARM))) chrome.alarms.create(ALARM, { periodInMinutes: 1, delayInMinutes: 0.1 }); + chrome.contextMenus.removeAll(() => { + chrome.contextMenus.create({ id: MENU, title: 'Als DeskHub-Ticket anlegen', contexts: ['selection', 'page'] }); + }); +} + +chrome.runtime.onInstalled.addListener(ensureSetup); +chrome.runtime.onStartup.addListener(async () => { await ensureSetup(); poll(); }); + +/** Fragt den Server nach Neuem seit dem letzten Mal. Merkt sich die Serverzeit als Bezugspunkt (kein Uhrzeit-Abgleich mit dem PC nötig). */ +export async function poll() { + const session = await getSession(); + if (!session) { await setBadge(0); return { skipped: true }; } + const { lastSeen, badgeCount = 0 } = await chrome.storage.local.get(['lastSeen', 'badgeCount']); + try { + const { data } = await apiCall('activity.php', { query: lastSeen ? { since: lastSeen } : {} }); + const fresh = (data.newReplies || 0) + (data.newUnassigned || 0); + const total = badgeCount + fresh; + await chrome.storage.local.set({ lastSeen: data.serverTime, badgeCount: total }); + await setBadge(total); + if (fresh > 0 && (await getSettings()).notifications) { + const parts = []; + if (data.newUnassigned) parts.push(`${data.newUnassigned} neu und nicht zugewiesen`); + if (data.newReplies) parts.push(`${data.newReplies} Aktualisierung${data.newReplies === 1 ? '' : 'en'} bei deinen Tickets`); + chrome.notifications.create('deskhub-new', { type: 'basic', iconUrl: 'icons/icon128.png', title: 'DeskHub', message: parts.join(', '), priority: 1 }); + } + return { fresh, total }; + } catch (e) { + if (e.status === 401) { // Anmeldung abgelaufen: Zähler zeigt „!“, bis sich jemand neu anmeldet + await setBadge(0); + await chrome.action.setBadgeText({ text: '!' }); + await chrome.action.setBadgeBackgroundColor({ color: '#b91c1c' }); + chrome.notifications.create('deskhub-login', { type: 'basic', iconUrl: 'icons/icon128.png', title: 'DeskHub', message: 'Bitte in der Erweiterung neu anmelden.' }); + } + return { error: e.code || 'ERROR' }; // Netzwerkfehler: still bleiben, beim nächsten Mal erneut + } +} + +chrome.alarms.onAlarm.addListener((alarm) => { if (alarm.name === ALARM) poll(); }); + +async function openCreateWindow() { + return chrome.windows.create({ url: chrome.runtime.getURL('popup.html?window=1&create=1'), type: 'popup', width: 440, height: 680 }); +} + +chrome.contextMenus.onClicked.addListener(async (info, tab) => { + if (info.menuItemId !== MENU) return; + const subject = truncate((tab?.title || '').trim(), 120); + const selection = (info.selectionText || '').trim(); + const source = tab?.url && /^https?:/i.test(tab.url) ? `\n\nQuelle: ${tab.url}` : ''; + await chrome.storage.local.set({ prefill: { subject, message: (selection + source).trim() } }); + await openCreateWindow(); +}); + +chrome.notifications.onClicked.addListener(async (id) => { + chrome.notifications.clear(id); + try { await chrome.action.openPopup(); } catch { await chrome.windows.create({ url: chrome.runtime.getURL('popup.html?window=1'), type: 'popup', width: 440, height: 680 }); } +}); + +chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { + (async () => { + if (msg?.type === 'reset-badge') { await chrome.storage.local.set({ badgeCount: 0 }); await setBadge(0); sendResponse({ ok: true }); } + else if (msg?.type === 'poll-now') sendResponse(await poll()); + else if (msg?.type === 'session-changed') { await chrome.storage.local.remove(['lastSeen', 'badgeCount']); await setBadge(0); sendResponse(await poll()); } + else sendResponse({ ok: false }); + })(); + return true; // Antwort kommt asynchron +}); diff --git a/chrome-erweiterung/icons/icon128.png b/chrome-erweiterung/icons/icon128.png new file mode 100644 index 00000000..f0b6d272 Binary files /dev/null and b/chrome-erweiterung/icons/icon128.png differ diff --git a/chrome-erweiterung/icons/icon16.png b/chrome-erweiterung/icons/icon16.png new file mode 100644 index 00000000..ec1cb455 Binary files /dev/null and b/chrome-erweiterung/icons/icon16.png differ diff --git a/chrome-erweiterung/icons/icon32.png b/chrome-erweiterung/icons/icon32.png new file mode 100644 index 00000000..1f1eccd6 Binary files /dev/null and b/chrome-erweiterung/icons/icon32.png differ diff --git a/chrome-erweiterung/icons/icon48.png b/chrome-erweiterung/icons/icon48.png new file mode 100644 index 00000000..aede7d69 Binary files /dev/null and b/chrome-erweiterung/icons/icon48.png differ diff --git a/chrome-erweiterung/lib/core.js b/chrome-erweiterung/lib/core.js new file mode 100644 index 00000000..d804b1fb --- /dev/null +++ b/chrome-erweiterung/lib/core.js @@ -0,0 +1,119 @@ +// Gemeinsamer Kern von Popup und Hintergrunddienst: Serveradresse, Anmeldung, API-Aufrufe, Hilfsfunktionen. +// Das Anmelde-Token liegt nur in chrome.storage.local dieser Erweiterung (nie in sync, nie im Seitencode). + +export class ApiError extends Error { + constructor(status, code, message) { super(message); this.status = status; this.code = code; } +} + +const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); + +/** Bereinigt die Serveradresse wie das Windows-Programm: https ist Pflicht (http nur für lokale Tests), kein Benutzer/Suchtext/Anker. */ +export function normalizeServerUrl(input) { + let s = String(input || '').trim(); + if (!s) throw new Error('Bitte die Server-Adresse eingeben.'); + if (!s.includes('://')) s = 'https://' + s; + let u; + try { u = new URL(s); } catch { throw new Error('Die Server-Adresse ist ungültig.'); } + const local = LOCAL_HOSTS.has(u.hostname); + if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) throw new Error('Die Server-Adresse muss HTTPS verwenden.'); + if (u.username || u.password || u.search || u.hash) throw new Error('Die Server-Adresse ist ungültig.'); + return u.origin + u.pathname.replace(/\/+$/, ''); +} + +export async function getSession() { + const { session } = await chrome.storage.local.get('session'); + return session && session.token && session.serverUrl ? session : null; +} +export const setSession = (session) => chrome.storage.local.set({ session }); +export async function clearSession() { await chrome.storage.local.remove(['session', 'lastSeen', 'badgeCount', 'meta']); } + +export async function getSettings() { + const { settings } = await chrome.storage.local.get('settings'); + return { notifications: true, ...(settings || {}) }; +} +export const setSettings = (settings) => chrome.storage.local.set({ settings }); + +/** Ein Aufruf der Mitarbeiter-API. Token wird wie bei den Apps in zwei Headern gesendet (viele Hoster entfernen „Authorization“). */ +export async function apiCall(path, { method = 'GET', query, body, auth = true, serverUrl, signal } = {}) { + const session = auth || !serverUrl ? await getSession() : null; + const base = serverUrl || session?.serverUrl; + if (!base) throw new ApiError(401, 'UNAUTHENTICATED', 'Nicht angemeldet.'); + const url = new URL(`${base}/api/staff/v1/${path}`); + for (const [k, v] of Object.entries(query || {})) if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v)); + const headers = { Accept: 'application/json', 'Cache-Control': 'no-cache' }; + if (auth) { + if (!session?.token) throw new ApiError(401, 'UNAUTHENTICATED', 'Nicht angemeldet.'); + headers.Authorization = `Bearer ${session.token}`; + headers['X-DeskHub-Authorization'] = `Bearer ${session.token}`; + } + const init = { method, headers, redirect: 'error', credentials: 'omit', signal: signal || AbortSignal.timeout(30000) }; + if (body !== undefined && method !== 'GET') { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); } + let res; + try { res = await fetch(url, init); } catch { throw new ApiError(0, 'NETWORK', 'Server nicht erreichbar.'); } + const text = await res.text(); + let data = null; + try { data = text ? JSON.parse(text) : null; } catch { /* kein JSON */ } + if (!res.ok && res.status !== 202) { + if (res.status === 401 && auth) await clearSession(); + throw new ApiError(res.status, data?.error?.code || `HTTP_${res.status}`, data?.error?.message || `Serverfehler (${res.status}).`); + } + return { status: res.status, data }; +} + +/** Fragt bei Bedarf die Berechtigung für genau diesen Server ab (nur diese eine Adresse, nicht „alle Webseiten“). Muss aus einem Klick heraus aufgerufen werden. */ +export async function ensureHostPermission(serverUrl) { + const pattern = new URL(serverUrl).origin + '/*'; + if (await chrome.permissions.contains({ origins: [pattern] })) return true; + return chrome.permissions.request({ origins: [pattern] }); +} + +/** Meldet an. @returns {{requiresTotp:true}|{session:object}} */ +export async function login(serverUrlInput, email, password, totpCode) { + const serverUrl = normalizeServerUrl(serverUrlInput); + if (!(await ensureHostPermission(serverUrl))) throw new Error('Ohne Erlaubnis für diesen Server kann sich die Erweiterung nicht anmelden.'); + const { status, data } = await apiCall('login.php', { + method: 'POST', auth: false, serverUrl, + body: { email, password, totpCode: totpCode || null, deviceName: 'Chrome-Erweiterung' }, + }); + if (status === 202 || data?.requiresTotp) return { requiresTotp: true }; + const session = { serverUrl, token: data.token, expiresAt: data.expiresAt, staff: data.staff }; + await setSession(session); + return { session }; +} + +/** Meldet ab (Token auf dem Server sperren) und entfernt Daten und Berechtigung dieses Servers. */ +export async function logout() { + const session = await getSession(); + if (session) { + try { await apiCall('logout.php', { method: 'POST', body: {} }); } catch { /* Token läuft ohnehin ab */ } + try { await chrome.permissions.remove({ origins: [new URL(session.serverUrl).origin + '/*'] }); } catch { /* ignorieren */ } + } + await clearSession(); +} + +/** Pfad eines Tickets in der Weboberfläche. Nur Ticketnummern im bekannten Format. */ +export function ticketPath(number) { + const n = String(number || ''); + if (!/^[A-Za-z0-9._-]{1,40}$/.test(n)) throw new Error('Ungültige Ticketnummer.'); + return `/staff/ticket_view.php?n=${n}`; +} + +/** Öffnet eine Seite der Weboberfläche in einem neuen Tab. Der Server gibt dafür eine einmalige Anmelde-Adresse (und prüft die Rechte). */ +export async function openWeb(path) { + const session = await getSession(); + if (!session) throw new ApiError(401, 'UNAUTHENTICATED', 'Nicht angemeldet.'); + const { data } = await apiCall('web_pages.php', { method: 'POST', body: { path } }); + const u = new URL(String(data?.url || '')); + const base = new URL(session.serverUrl); + if ((u.protocol !== 'https:' && !LOCAL_HOSTS.has(u.hostname)) || u.origin !== base.origin || !u.pathname.endsWith('/staff/app_login.php')) { + throw new Error('Der Server hat eine unerwartete Adresse geliefert.'); + } + return chrome.tabs.create({ url: u.href }); +} + +/** Maskiert Text und macht Zeilenumbrüche zu
: Der Server nimmt Ticket-Nachrichten als HTML an und bereinigt sie zusätzlich. */ +export function plainToHtml(text) { + return String(text ?? '').replace(/&/g, '&').replace(//g, '>').replace(/\r\n|\r|\n/g, '
'); +} + +export const truncate = (s, n) => (String(s).length > n ? String(s).slice(0, n - 1) + '…' : String(s)); diff --git a/chrome-erweiterung/manifest.json b/chrome-erweiterung/manifest.json new file mode 100644 index 00000000..febd9e4a --- /dev/null +++ b/chrome-erweiterung/manifest.json @@ -0,0 +1,19 @@ +{ + "manifest_version": 3, + "name": "DeskHub", + "version": "1.0.0", + "description": "DeskHub in der Browserleiste: Zähler und Benachrichtigungen für neue Tickets, Suche und schnell ein Ticket anlegen.", + "minimum_chrome_version": "116", + "icons": { "16": "icons/icon16.png", "32": "icons/icon32.png", "48": "icons/icon48.png", "128": "icons/icon128.png" }, + "action": { + "default_title": "DeskHub", + "default_popup": "popup.html", + "default_icon": { "16": "icons/icon16.png", "32": "icons/icon32.png" } + }, + "background": { "service_worker": "background.js", "type": "module" }, + "permissions": ["storage", "alarms", "notifications", "contextMenus"], + "optional_host_permissions": ["https://*/*", "http://localhost/*", "http://127.0.0.1/*"], + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'; connect-src https: http://localhost:* http://127.0.0.1:*" + } +} diff --git a/chrome-erweiterung/pack.mjs b/chrome-erweiterung/pack.mjs new file mode 100644 index 00000000..9eb23437 --- /dev/null +++ b/chrome-erweiterung/pack.mjs @@ -0,0 +1,41 @@ +// Packt die Erweiterung als ZIP (für „Entpackte Erweiterung laden“ nicht nötig, aber für den Chrome Web Store oder zum Weitergeben). +// Start: node pack.mjs [Zielordner] (Standard: ../release). Enthalten sind nur die Laufzeitdateien, keine Tests. +import { readFileSync, readdirSync, statSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join, relative, sep } from 'node:path'; +import { deflateRawSync, crc32 } from 'node:zlib'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('.', import.meta.url)); +const out = process.argv[2] || join(root, '..', 'release'); +const include = ['manifest.json', 'background.js', 'popup.html', 'popup.css', 'popup.js', 'lib', 'icons']; + +const files = []; +(function walk(p) { + const st = statSync(p); + if (st.isDirectory()) for (const f of readdirSync(p).sort()) walk(join(p, f)); + else files.push(p); +})(root); +const wanted = files.filter((f) => include.some((i) => relative(root, f).split(sep)[0] === i)).sort(); + +const version = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8')).version; +const parts = []; const central = []; let offset = 0; +const dos = new Date(2026, 0, 1, 0, 0, 0); // fester Zeitstempel: gleiches Ergebnis bei jedem Packen +const dosTime = (dos.getHours() << 11) | (dos.getMinutes() << 5) | (dos.getSeconds() >> 1); +const dosDate = ((dos.getFullYear() - 1980) << 9) | ((dos.getMonth() + 1) << 5) | dos.getDate(); +for (const file of wanted) { + const name = Buffer.from(relative(root, file).split(sep).join('/'), 'utf8'); // immer mit / (Chrome verlangt das) + const data = readFileSync(file); const comp = deflateRawSync(data, { level: 9 }); const crc = crc32(data) >>> 0; + const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0800, 6); local.writeUInt16LE(8, 8); + local.writeUInt16LE(dosTime, 10); local.writeUInt16LE(dosDate, 12); local.writeUInt32LE(crc, 14); local.writeUInt32LE(comp.length, 18); local.writeUInt32LE(data.length, 22); local.writeUInt16LE(name.length, 26); + parts.push(local, name, comp); + const c = Buffer.alloc(46); c.writeUInt32LE(0x02014b50, 0); c.writeUInt16LE(20, 4); c.writeUInt16LE(20, 6); c.writeUInt16LE(0x0800, 8); c.writeUInt16LE(8, 10); + c.writeUInt16LE(dosTime, 12); c.writeUInt16LE(dosDate, 14); c.writeUInt32LE(crc, 16); c.writeUInt32LE(comp.length, 20); c.writeUInt32LE(data.length, 24); c.writeUInt16LE(name.length, 28); c.writeUInt32LE(offset, 42); + central.push(c, name); + offset += 30 + name.length + comp.length; +} +const cd = Buffer.concat(central); +const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50, 0); end.writeUInt16LE(wanted.length, 8); end.writeUInt16LE(wanted.length, 10); end.writeUInt32LE(cd.length, 12); end.writeUInt32LE(offset, 16); +mkdirSync(out, { recursive: true }); +const target = join(out, `deskhub-chrome-erweiterung-${version}.zip`); +writeFileSync(target, Buffer.concat([...parts, cd, end])); +console.log(`${wanted.length} Dateien -> ${target}`); diff --git a/chrome-erweiterung/popup.css b/chrome-erweiterung/popup.css new file mode 100644 index 00000000..7161dc80 --- /dev/null +++ b/chrome-erweiterung/popup.css @@ -0,0 +1,50 @@ +:root { + --bg: #f3f6f8; --card: #ffffff; --ink: #162033; --muted: #5d6b82; --line: #e2e8f0; + --brand: #0f766e; --brand-ink: #ffffff; --brand-soft: #e0f5f2; --bad: #b91c1c; --bad-bg: #fee2e2; --ok: #15803d; --ok-bg: #dcfce7; +} +@media (prefers-color-scheme: dark) { + :root { --bg: #0b121b; --card: #131e2c; --ink: #e6ecf3; --muted: #93a2b8; --line: #243245; --brand: #2dd4bf; --brand-ink: #04201d; --brand-soft: #0f2a2b; --bad: #f87171; --bad-bg: #3a1618; --ok: #4ade80; --ok-bg: #12301f; } +} +* { box-sizing: border-box; } +body { margin: 0; width: 380px; min-height: 240px; background: var(--bg); color: var(--ink); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; } +body.window { width: auto; } +#app { display: flex; flex-direction: column; min-height: 240px; } +header.top { display: flex; align-items: center; gap: 8px; padding: 10px 14px; background: #0f1b2b; color: #fff; } +header.top img { width: 22px; height: 22px; border-radius: 6px; } +header.top .name { font-weight: 700; flex: 1; } +header.top button { background: transparent; color: #cbd5e1; border: 0; padding: 2px 6px; font: inherit; font-size: 12px; cursor: pointer; border-radius: 6px; } +header.top button:hover { background: rgba(255,255,255,.1); color: #fff; } +nav.tabs { display: flex; background: var(--card); border-bottom: 1px solid var(--line); } +nav.tabs button { flex: 1; padding: 9px 4px; background: none; border: 0; border-bottom: 2px solid transparent; color: var(--muted); font: inherit; font-weight: 600; cursor: pointer; } +nav.tabs button.on { color: var(--brand); border-color: var(--brand); } +main { padding: 12px 14px; display: grid; gap: 10px; align-content: start; flex: 1; } +.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px; } +h1 { font-size: 16px; margin: 0 0 4px; } +.muted { color: var(--muted); font-size: 12.5px; } +.stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } +.stat { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; } +.stat .v { font-size: 22px; font-weight: 800; line-height: 1.1; } +.stat .k { color: var(--muted); font-size: 11.5px; } +.stat.warn .v { color: var(--bad); } +ul.tickets { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; } +ul.tickets li button { width: 100%; text-align: left; display: grid; grid-template-columns: 10px 1fr; gap: 4px 8px; background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; color: inherit; font: inherit; cursor: pointer; } +ul.tickets li button:hover { border-color: var(--brand); } +.dot { width: 10px; height: 10px; border-radius: 50%; margin-top: 5px; background: var(--muted); } +.t-sub { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.t-meta { grid-column: 2; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.t-meta .over { color: var(--bad); font-weight: 600; } +form { display: grid; gap: 8px; } +label { display: grid; gap: 3px; font-size: 12.5px; font-weight: 600; } +label.inline { display: flex; align-items: center; gap: 6px; font-weight: 400; } +input[type=text], input[type=email], input[type=password], input[type=search], select, textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; background: var(--card); color: var(--ink); font: inherit; } +textarea { min-height: 90px; resize: vertical; } +input:focus, select:focus, textarea:focus { outline: 2px solid var(--brand-soft); border-color: var(--brand); } +button.primary { background: var(--brand); color: var(--brand-ink); border: 0; border-radius: 8px; padding: 9px 14px; font: inherit; font-weight: 700; cursor: pointer; } +button.primary:disabled { opacity: .6; cursor: default; } +button.link { background: none; border: 0; color: var(--brand); font: inherit; cursor: pointer; padding: 0; text-decoration: underline; } +.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.msg { padding: 8px 10px; border-radius: 8px; font-size: 13px; } +.msg.err { background: var(--bad-bg); color: var(--bad); } +.msg.ok { background: var(--ok-bg); color: var(--ok); } +.empty { text-align: center; color: var(--muted); padding: 18px 0; } +footer.bar { padding: 8px 14px; border-top: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; font-size: 12px; color: var(--muted); } diff --git a/chrome-erweiterung/popup.html b/chrome-erweiterung/popup.html new file mode 100644 index 00000000..a6b9f76e --- /dev/null +++ b/chrome-erweiterung/popup.html @@ -0,0 +1,13 @@ + + + + + + DeskHub + + + +
+ + + diff --git a/chrome-erweiterung/popup.js b/chrome-erweiterung/popup.js new file mode 100644 index 00000000..d92da70b --- /dev/null +++ b/chrome-erweiterung/popup.js @@ -0,0 +1,189 @@ +// Oberfläche der Erweiterung: Anmeldung, Übersicht, Suche, Ticket anlegen. Es wird nur mit DOM-Aufrufen gebaut (kein innerHTML mit Serverdaten). +import { apiCall, getSession, getSettings, setSettings, login, logout, openWeb, ticketPath, plainToHtml, normalizeServerUrl } from './lib/core.js'; + +const app = document.getElementById('app'); +const params = new URLSearchParams(location.search); +if (params.get('window')) document.body.classList.add('window'); + +/** Kleiner Baumeister: h('div', {class:'x', onclick: fn}, 'Text', kind, …) */ +function h(tag, attrs = {}, ...kids) { + const el = document.createElement(tag); + for (const [k, v] of Object.entries(attrs || {})) { + if (v === undefined || v === null || v === false) continue; + if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2), v); + else if (k === 'class') el.className = v; + else if (k === 'value') el.value = v; + else if (k === 'checked') el.checked = !!v; + else el.setAttribute(k, v === true ? '' : String(v)); + } + for (const kid of kids.flat()) { if (kid === null || kid === undefined || kid === false) continue; el.append(kid.nodeType ? kid : document.createTextNode(String(kid))); } + return el; +} +const clear = (el) => { while (el.firstChild) el.removeChild(el.firstChild); }; +const show = (...nodes) => { clear(app); app.append(...nodes); }; +const errText = (e) => (e && e.message) || 'Es ist ein Fehler aufgetreten.'; + +// ─── Anmeldung ────────────────────────────────────────────────────────────── +async function renderLogin(prefill = {}) { + const { lastLogin } = await chrome.storage.local.get('lastLogin'); + const start = { serverUrl: prefill.serverUrl ?? lastLogin?.serverUrl ?? '', email: prefill.email ?? lastLogin?.email ?? '', needTotp: !!prefill.needTotp, error: prefill.error || '' }; + let password = prefill.password || ''; + const msg = h('div', { class: 'msg err', hidden: start.error ? undefined : true, role: 'alert' }, start.error); + const server = h('input', { type: 'text', id: 'server', placeholder: 'support.example.de', value: start.serverUrl, autocomplete: 'url', required: true }); + const email = h('input', { type: 'email', id: 'email', value: start.email, autocomplete: 'username', required: true }); + const pass = h('input', { type: 'password', id: 'pass', autocomplete: 'current-password', required: !start.needTotp, value: start.needTotp ? '' : undefined }); + const code = h('input', { type: 'text', id: 'code', inputmode: 'numeric', pattern: '[0-9]{6}', maxlength: '6', autocomplete: 'one-time-code', placeholder: '6-stelliger Code' }); + const button = h('button', { class: 'primary', type: 'submit' }, start.needTotp ? 'Bestätigen' : 'Anmelden'); + const form = h('form', { + onsubmit: async (ev) => { + ev.preventDefault(); msg.hidden = true; button.disabled = true; + try { + if (!start.needTotp) password = pass.value; + const r = await login(server.value, email.value.trim(), password, start.needTotp ? code.value.trim() : ''); + if (r.requiresTotp) { password = pass.value || password; return renderLogin({ serverUrl: server.value, email: email.value, password, needTotp: true }); } + await chrome.storage.local.set({ lastLogin: { serverUrl: normalizeServerUrl(server.value), email: email.value.trim() } }); + password = ''; + await chrome.runtime.sendMessage({ type: 'session-changed' }); + renderMain(r.session); + } catch (e) { msg.textContent = errText(e); msg.hidden = false; button.disabled = false; } + }, + }, + start.needTotp ? h('p', { class: 'muted' }, 'Dein Konto ist mit einem Authenticator geschützt. Bitte den aktuellen Code eingeben.') : null, + start.needTotp ? null : h('label', {}, 'Server-Adresse', server), + start.needTotp ? null : h('label', {}, 'E-Mail-Adresse', email), + start.needTotp ? null : h('label', {}, 'Passwort', pass), + start.needTotp ? h('label', {}, 'Authenticator-Code', code) : null, + msg, button); + show( + h('header', { class: 'top' }, h('img', { src: 'icons/icon48.png', alt: '' }), h('span', { class: 'name' }, 'DeskHub')), + h('main', {}, h('div', { class: 'card' }, h('h1', {}, 'Anmelden'), h('p', { class: 'muted' }, 'Mit deinem Mitarbeiter-Konto. Die Erweiterung fragt dich einmal nach der Erlaubnis, auf deinen DeskHub-Server zuzugreifen (nur auf diesen, nicht auf andere Webseiten).'), form))); + (start.needTotp ? code : (start.serverUrl ? pass : server)).focus(); +} + +// ─── Hauptansicht ─────────────────────────────────────────────────────────── +async function renderMain(session, tab = 'overview') { + chrome.runtime.sendMessage({ type: 'reset-badge' }).catch(() => {}); + const canCreate = session.staff?.permissions?.create_ticket !== false; + if (tab === 'create' && !canCreate) tab = 'overview'; + const body = h('div', { class: 'body', style: 'display:contents' }); + const tabs = [['overview', 'Übersicht'], ['search', 'Suche'], ...(canCreate ? [['create', 'Neu']] : [])]; + const nav = h('nav', { class: 'tabs' }, tabs.map(([key, label]) => h('button', { class: key === tab ? 'on' : '', onclick: () => renderMain(session, key) }, label))); + const settings = await getSettings(); + const footer = h('footer', { class: 'bar' }, + h('label', { class: 'inline' }, h('input', { type: 'checkbox', checked: settings.notifications, onchange: (ev) => setSettings({ ...settings, notifications: ev.target.checked }) }), 'Benachrichtigungen'), + h('span', {}, new URL(session.serverUrl).host)); + show( + h('header', { class: 'top' }, h('img', { src: 'icons/icon48.png', alt: '' }), h('span', { class: 'name' }, session.staff?.name || 'DeskHub'), + h('button', { onclick: async () => { await logout(); await chrome.runtime.sendMessage({ type: 'session-changed' }).catch(() => {}); renderLogin(); } }, 'Abmelden')), + nav, body, footer); + const main = h('main'); + body.append(main); + if (tab === 'overview') await renderOverview(main, session); + else if (tab === 'search') renderSearch(main, session); + else await renderCreate(main, session); +} + +/** Behandelt Fehler zentral: abgelaufene Anmeldung führt zur Anmeldung zurück. */ +function fail(el, e, session) { + if (e.status === 401) { renderLogin({ serverUrl: session?.serverUrl, error: 'Die Anmeldung ist abgelaufen. Bitte neu anmelden.' }); return; } + clear(el); el.append(h('div', { class: 'msg err', role: 'alert' }, errText(e))); +} + +async function openTicket(number, host) { + try { await openWeb(ticketPath(number)); } + catch (e) { const old = host.querySelector('.msg.err'); if (old) old.remove(); host.prepend(h('div', { class: 'msg err', role: 'alert' }, errText(e))); } +} + +function ticketList(items, host) { + if (!items.length) return h('div', { class: 'empty' }, 'Keine Tickets.'); + return h('ul', { class: 'tickets' }, items.map((t) => h('li', {}, h('button', { onclick: () => openTicket(t.number, host), title: `Ticket ${t.number} im Browser öffnen` }, + h('span', { class: 'dot', style: t.priority?.color && /^#[0-9a-fA-F]{3,8}$/.test(t.priority.color) ? `background:${t.priority.color}` : null }), + h('span', { class: 't-sub' }, t.subject), + h('span', { class: 't-meta' }, `#${t.number} · ${t.requesterName || '—'} · `, t.isOverdue ? h('span', { class: 'over' }, t.dueLabel || 'überfällig') : (t.dueLabel || t.department?.name || '')))))); +} + +async function renderOverview(main, session) { + main.append(h('div', { class: 'empty' }, 'Lade …')); + try { + const { data } = await apiCall('dashboard.php'); + clear(main); + main.append( + h('div', { class: 'stats' }, + h('div', { class: 'stat' }, h('div', { class: 'v' }, data.myOpen ?? 0), h('div', { class: 'k' }, 'Meine offenen')), + h('div', { class: data.overdue ? 'stat warn' : 'stat' }, h('div', { class: 'v' }, data.overdue ?? 0), h('div', { class: 'k' }, 'Überfällig')), + h('div', { class: 'stat' }, h('div', { class: 'v' }, data.unassigned ?? 0), h('div', { class: 'k' }, 'Nicht zugewiesen'))), + h('div', { class: 'muted' }, 'Als Nächstes fällig (meine und nicht zugewiesene)'), + ticketList(data.recent || [], main)); + } catch (e) { fail(main, e, session); } +} + +function renderSearch(main, session) { + const results = h('div'); + const input = h('input', { type: 'search', placeholder: 'Betreff, Nummer, Name, Text …', 'aria-label': 'Suche', maxlength: '100' }); + main.append(h('form', { onsubmit: async (ev) => { + ev.preventDefault(); + const q = input.value.trim(); + if (q.length < 2) return; + clear(results); results.append(h('div', { class: 'empty' }, 'Suche …')); + try { + const { data } = await apiCall('tickets.php', { query: { q, status: 'open,pending,answered,on_hold,resolved,closed', perPage: 20 } }); + clear(results); + results.append(h('div', { class: 'muted' }, `${data.total} Treffer${data.hasMore ? ' (die ersten 20)' : ''}`), ticketList(data.items || [], results)); + } catch (e) { fail(results, e, session); } + } }, input, h('button', { class: 'primary', type: 'submit' }, 'Suchen')), results); + input.focus(); +} + +async function loadMeta() { + const { meta } = await chrome.storage.local.get('meta'); + if (meta && Date.now() - meta.at < 10 * 60 * 1000) return meta; + const { data } = await apiCall('metadata.php'); + const fresh = { at: Date.now(), departments: (data.departments || []).filter((d) => !/\(deaktiviert\)$/.test(d.name)), priorities: data.priorities || [] }; + await chrome.storage.local.set({ meta: fresh }); + return fresh; +} + +async function renderCreate(main, session) { + main.append(h('div', { class: 'empty' }, 'Lade …')); + let meta; + try { meta = await loadMeta(); } catch (e) { return fail(main, e, session); } + const { prefill } = await chrome.storage.local.get('prefill'); + if (prefill) chrome.storage.local.remove('prefill'); + clear(main); + const out = h('div'); + const f = { + name: h('input', { type: 'text', value: '', required: true, maxlength: '120', placeholder: 'Name des Anfragers' }), + mail: h('input', { type: 'email', placeholder: 'optional' }), + subject: h('input', { type: 'text', value: prefill?.subject || '', required: true, maxlength: '255' }), + message: h('textarea', { required: true, value: prefill?.message || '' }), + dept: h('select', { required: true }, meta.departments.map((d) => h('option', { value: d.id }, d.name))), + prio: h('select', { required: true }, meta.priorities.map((p) => h('option', { value: p.id }, p.name))), + notify: h('input', { type: 'checkbox' }), + }; + const submit = h('button', { class: 'primary', type: 'submit' }, 'Ticket anlegen'); + main.append(h('form', { onsubmit: async (ev) => { + ev.preventDefault(); clear(out); submit.disabled = true; + try { + const { data } = await apiCall('ticket_create.php', { method: 'POST', body: { + requesterName: f.name.value.trim(), requesterEmail: f.mail.value.trim(), subject: f.subject.value.trim(), message: plainToHtml(f.message.value.trim()), + departmentId: Number(f.dept.value), priorityId: Number(f.prio.value), notifyCustomer: f.notify.checked } }); + const number = data.ticket?.number; + out.append(h('div', { class: 'msg ok', role: 'status' }, `Ticket #${number} wurde angelegt. `, h('button', { class: 'link', type: 'button', onclick: () => openTicket(number, out) }, 'Im Browser öffnen'))); + for (const k of ['name', 'mail', 'subject', 'message']) f[k].value = ''; + } catch (e) { if (e.status === 401) return fail(main, e, session); out.append(h('div', { class: 'msg err', role: 'alert' }, errText(e))); } + submit.disabled = false; + } }, + h('label', {}, 'Anfrager (Name)', f.name), h('label', {}, 'Anfrager (E-Mail)', f.mail), + h('label', {}, 'Betreff', f.subject), h('label', {}, 'Beschreibung', f.message), + h('div', { class: 'row2' }, h('label', {}, 'Abteilung', f.dept), h('label', {}, 'Priorität', f.prio)), + h('label', { class: 'inline' }, f.notify, 'Kunden per E-Mail benachrichtigen'), + out, submit)); + f.name.focus(); +} + +// ─── Start ────────────────────────────────────────────────────────────────── +(async () => { + const session = await getSession(); + if (!session) return renderLogin(); + renderMain(session, params.get('create') ? 'create' : 'overview'); +})(); diff --git a/chrome-erweiterung/test/core.test.mjs b/chrome-erweiterung/test/core.test.mjs new file mode 100644 index 00000000..e91acf9a --- /dev/null +++ b/chrome-erweiterung/test/core.test.mjs @@ -0,0 +1,229 @@ +// Tests des Kerns und des Hintergrunddienstes mit einem nachgebauten Browser (chrome.*) und einem nachgebauten Server (fetch). +// Start: node --test test/ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +// ── Nachbau von chrome.* ───────────────────────────────────────────────────────────────────────── +function makeChrome() { + const store = {}; + const calls = { notifications: [], badge: { text: '', color: '' }, tabs: [], permissionsRequested: [], permissionsRemoved: [], menus: [], windows: [] }; + const listener = () => ({ addListener() {} }); + const chromeMock = { + storage: { local: { + async get(keys) { const out = {}; for (const k of [].concat(keys ?? Object.keys(store))) if (k in store) out[k] = structuredClone(store[k]); return out; }, + async set(obj) { for (const [k, v] of Object.entries(obj)) store[k] = structuredClone(v); }, + async remove(keys) { for (const k of [].concat(keys)) delete store[k]; }, + } }, + permissions: { + granted: new Set(), allow: true, + async contains({ origins }) { return origins.every((o) => chromeMock.permissions.granted.has(o)); }, + async request({ origins }) { calls.permissionsRequested.push(...origins); if (chromeMock.permissions.allow) origins.forEach((o) => chromeMock.permissions.granted.add(o)); return chromeMock.permissions.allow; }, + async remove({ origins }) { calls.permissionsRemoved.push(...origins); origins.forEach((o) => chromeMock.permissions.granted.delete(o)); return true; }, + }, + tabs: { async create(o) { calls.tabs.push(o); return o; } }, + windows: { async create(o) { calls.windows.push(o); return o; } }, + action: { + async setBadgeText({ text }) { calls.badge.text = text; }, + async setBadgeBackgroundColor({ color }) { calls.badge.color = color; }, + async openPopup() {}, + }, + notifications: { create(id, o) { calls.notifications.push({ id, ...o }); }, clear() {}, onClicked: listener() }, + alarms: { async get() { return null; }, create() {}, onAlarm: listener() }, + contextMenus: { removeAll(cb) { cb?.(); }, create(o) { calls.menus.push(o); }, onClicked: listener() }, + runtime: { onInstalled: listener(), onStartup: listener(), onMessage: listener(), getURL: (p) => 'chrome-extension://x/' + p, async sendMessage() { return {}; } }, + }; + return { chromeMock, store, calls }; +} + +/** Nachbau des Servers: Antworten werden vorgegeben, alle Aufrufe mitgeschrieben. */ +function makeFetch(routes) { + const log = []; + const fn = async (url, init = {}) => { + const u = new URL(url); const key = `${init.method || 'GET'} ${u.pathname.split('/api/staff/v1/')[1]}`; + log.push({ key, url: u, init }); + const r = routes[key]; + if (!r) return new Response(JSON.stringify({ error: { code: 'NOT_FOUND', message: 'unbekannt' } }), { status: 404 }); + const res = typeof r === 'function' ? await r(u, init) : r; + return new Response(JSON.stringify(res.body ?? null), { status: res.status ?? 200, headers: { 'content-type': 'application/json' } }); + }; + fn.log = log; + return fn; +} + +let env; +async function fresh() { + env = makeChrome(); globalThis.chrome = env.chromeMock; + return await import('../lib/core.js?' + Math.random()); +} +const SERVER = 'https://support.example.de'; +const SESSION = { serverUrl: SERVER, token: 'T'.repeat(48), expiresAt: '2099-01-01 00:00:00', staff: { id: 1, name: 'Lukas', permissions: { create_ticket: true } } }; + +test('Serveradresse: bereinigen und ablehnen', async () => { + const { normalizeServerUrl } = await fresh(); + assert.equal(normalizeServerUrl('support.example.de'), 'https://support.example.de'); + assert.equal(normalizeServerUrl(' https://support.example.de/ '), 'https://support.example.de'); + assert.equal(normalizeServerUrl('https://example.de/helpdesk/'), 'https://example.de/helpdesk'); + assert.equal(normalizeServerUrl('http://127.0.0.1:8080'), 'http://127.0.0.1:8080'); + for (const bad of ['', 'http://support.example.de', 'https://user:pw@example.de', 'https://example.de/?x=1', 'https://example.de/#a', 'ftp://example.de', 'http://localhost.evil.de']) assert.throws(() => normalizeServerUrl(bad), undefined, bad); +}); + +test('apiCall sendet das Token in beiden Headern, keine Cookies, keine Weiterleitung', async () => { + const { apiCall, setSession } = await fresh(); + await setSession(SESSION); + globalThis.fetch = makeFetch({ 'GET tickets.php': { body: { items: [] } } }); + await apiCall('tickets.php', { query: { q: 'drucker', page: 2, leer: '' } }); + const { init, url } = globalThis.fetch.log[0]; + assert.equal(init.headers.Authorization, `Bearer ${SESSION.token}`); + assert.equal(init.headers['X-DeskHub-Authorization'], `Bearer ${SESSION.token}`); + assert.equal(init.credentials, 'omit'); assert.equal(init.redirect, 'error'); + assert.equal(url.searchParams.get('q'), 'drucker'); assert.equal(url.searchParams.get('page'), '2'); assert.equal(url.searchParams.has('leer'), false); +}); + +test('apiCall: 401 räumt die Anmeldung weg, Netzwerkfehler wird gemeldet', async () => { + const { apiCall, setSession, getSession, ApiError } = await fresh(); + await setSession(SESSION); + globalThis.fetch = makeFetch({ 'GET me.php': { status: 401, body: { error: { code: 'UNAUTHENTICATED', message: 'Abgelaufen.' } } } }); + await assert.rejects(apiCall('me.php'), (e) => e instanceof ApiError && e.status === 401 && e.message === 'Abgelaufen.'); + assert.equal(await getSession(), null); + await setSession(SESSION); + globalThis.fetch = async () => { throw new TypeError('fail'); }; + await assert.rejects(apiCall('me.php'), (e) => e.code === 'NETWORK' && e.status === 0); + assert.notEqual(await getSession(), null, 'ein Netzwerkfehler meldet nicht ab'); +}); + +test('Anmeldung: Berechtigung, Authenticator-Code, Speichern', async () => { + const { login, getSession } = await fresh(); + let step = 0; + globalThis.fetch = makeFetch({ 'POST login.php': (u, init) => { + const b = JSON.parse(init.body); + assert.equal(init.headers.Authorization, undefined, 'beim Anmelden gibt es noch kein Token'); + if (++step === 1) { assert.equal(b.totpCode, null); return { status: 202, body: { requiresTotp: true } }; } + assert.equal(b.totpCode, '123456'); + return { body: { token: 'N'.repeat(48), expiresAt: '2099-01-01', staff: { id: 2, name: 'Agent' } } }; + } }); + const r1 = await login('support.example.de', 'a@b.de', 'geheim', ''); + assert.deepEqual(r1, { requiresTotp: true }); + assert.deepEqual(env.calls.permissionsRequested, ['https://support.example.de/*'], 'nur dieser eine Server'); + assert.equal(await getSession(), null, 'ohne Code noch keine Anmeldung'); + const r2 = await login('support.example.de', 'a@b.de', 'geheim', '123456'); + assert.equal(r2.session.staff.name, 'Agent'); + assert.equal((await getSession()).serverUrl, SERVER); +}); + +test('Anmeldung ohne erteilte Berechtigung bricht ab, ohne etwas zu senden', async () => { + const { login } = await fresh(); + env.chromeMock.permissions.allow = false; + globalThis.fetch = makeFetch({}); + await assert.rejects(login('support.example.de', 'a@b.de', 'x', ''), /Erlaubnis/); + assert.equal(globalThis.fetch.log.length, 0); +}); + +test('Abmelden sperrt das Token, entfernt Berechtigung und Daten', async () => { + const { logout, setSession, getSession } = await fresh(); + await setSession(SESSION); await chrome.storage.local.set({ badgeCount: 3, lastSeen: 'x' }); + env.chromeMock.permissions.granted.add(SERVER + '/*'); + globalThis.fetch = makeFetch({ 'POST logout.php': { body: { ok: true } } }); + await logout(); + assert.equal(globalThis.fetch.log[0].key, 'POST logout.php'); + assert.deepEqual(env.calls.permissionsRemoved, [SERVER + '/*']); + assert.equal(await getSession(), null); + assert.deepEqual(await chrome.storage.local.get(['badgeCount', 'lastSeen']), {}); +}); + +test('Ticketpfad und Textumwandlung', async () => { + const { ticketPath, plainToHtml } = await fresh(); + assert.equal(ticketPath('T-2026-0001'), '/staff/ticket_view.php?n=T-2026-0001'); + for (const bad of ['', 'a b', '../x', 'x&y=1', ' & "Anführungszeichen"'; + const { data, status } = await core.apiCall('ticket_create.php', { method: 'POST', body: { + requesterName: 'Max Mustermann', requesterEmail: '', subject: 'Test aus der Erweiterung', message: core.plainToHtml(msg), + departmentId: meta.departments[0].id, priorityId: meta.priorities[0].id, notifyCustomer: false } }); + assert.equal(status, 201); + created = data.ticket; + assert.match(created.number, /\S+/); assert.equal(created.subject, 'Test aus der Erweiterung'); + const second = await bg.poll(); + assert.ok(second.fresh >= 1, 'neues Ticket wird gezählt: ' + JSON.stringify(second)); + assert.equal(badge, String(second.total)); + assert.ok(notes.some((n) => n.id === 'deskhub-new' && /nicht zugewiesen/.test(n.message))); +}); + +test('Pflichtfelder werden vom Server mit deutscher Meldung abgelehnt', { skip }, async () => { + const meta = (await core.apiCall('metadata.php')).data; + await assert.rejects(core.apiCall('ticket_create.php', { method: 'POST', body: { requesterName: '', subject: 'x', message: 'y', departmentId: meta.departments[0].id, priorityId: meta.priorities[0].id } }), (e) => e.status === 422 && e.code === 'REQUESTER_NAME_REQUIRED'); + await assert.rejects(core.apiCall('ticket_create.php', { method: 'POST', body: { requesterName: 'A', subject: '', message: 'y', departmentId: meta.departments[0].id, priorityId: meta.priorities[0].id } }), (e) => e.code === 'SUBJECT_REQUIRED'); +}); + +test('Text mit Sonderzeichen: kommt als Klartext unverändert zurück (gespeichert wird maskiertes HTML)', { skip }, async () => { + const ticket = (await core.apiCall('ticket.php', { query: { number: created.number } })).data.ticket; + // Der Server speichert „<script>“ und liefert den Text für Apps als Klartext aus. Die Erweiterung zeigt Nachrichten nie als HTML an. + assert.equal(ticket.threads[0].message, 'Zeile 1\nZeile 2 & "Anführungszeichen"'); +}); + +test('Ticket im Browser öffnen: einmalige Anmelde-Adresse des eigenen Servers', { skip }, async () => { + tabs.length = 0; + await core.openWeb(core.ticketPath(created.number)); + const u = new URL(tabs[0].url); + assert.equal(u.origin, new URL(URL_).origin); + assert.ok(u.pathname.endsWith('/staff/app_login.php')); +}); + +test('Eine Seite, die nicht freigegeben ist, wird nicht geöffnet', { skip }, async () => { + tabs.length = 0; + await assert.rejects(core.apiCall('web_pages.php', { method: 'POST', body: { path: '/staff/../config/config.php' } }), (e) => e.status === 403 && e.code === 'PAGE_NOT_ALLOWED'); + assert.equal(tabs.length, 0); +}); + +test('Abmelden sperrt das Token auf dem Server', { skip }, async () => { + const token = (await core.getSession()).token; + await core.logout(); + assert.equal(await core.getSession(), null); + assert.equal(granted.size, 0, 'Berechtigung wurde zurückgegeben'); + const res = await fetch(`${URL_}/api/staff/v1/me.php`, { headers: { Authorization: `Bearer ${token}`, 'X-DeskHub-Authorization': `Bearer ${token}` } }); + assert.equal(res.status, 401, 'das alte Token gilt nicht mehr'); +});