216 lines
12 KiB
JavaScript
216 lines
12 KiB
JavaScript
// Gemeinsamer Kern von Oberfläche und Hintergrunddienst: Serveradresse, Anmeldung, API-Aufrufe, Hilfsfunktionen.
|
||
// Das Anmelde-Token liegt nur in chrome.storage.local dieser Erweiterung (nie in sync, nie im Seitencode).
|
||
// Der Zugriff auf den Server ist im Manifest erlaubt (host_permissions): Es gibt keine Rückfrage beim Anmelden.
|
||
|
||
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() {
|
||
const drafts = Object.keys(await chrome.storage.local.get(null)).filter((k) => k.startsWith('draft:')); // nicht gesendete Entwürfe gehören zur Anmeldung
|
||
await chrome.storage.local.remove(['session', 'lastSeen', 'badgeCount', 'meta', ...drafts]);
|
||
}
|
||
|
||
export const DEFAULT_SETTINGS = { notifications: true, notifyUnassigned: true, notifyMine: true, theme: 'auto', compact: false };
|
||
export async function getSettings() {
|
||
const { settings } = await chrome.storage.local.get('settings');
|
||
return { ...DEFAULT_SETTINGS, ...(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' };
|
||
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', cache: 'no-store', 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 };
|
||
}
|
||
|
||
/**
|
||
* Schickt eine POST-Anfrage los, ohne auf etwas zu warten (für „beim Verlassen aufräumen“). Läuft synchron mit keepalive und überlebt deshalb
|
||
* das Schließen des Popups, das sich bei jedem Klick daneben schließt. Die Sitzung wird übergeben, weil ein Speicherzugriff dafür zu spät käme.
|
||
*/
|
||
export function apiBeacon(session, path, body) {
|
||
if (!session?.token || !session?.serverUrl) return;
|
||
try {
|
||
fetch(`${session.serverUrl}/api/staff/v1/${path}`, {
|
||
method: 'POST', keepalive: true, redirect: 'error', credentials: 'omit', cache: 'no-store',
|
||
headers: { Accept: 'application/json', 'Content-Type': 'application/json', Authorization: `Bearer ${session.token}`, 'X-DeskHub-Authorization': `Bearer ${session.token}` },
|
||
body: JSON.stringify(body),
|
||
}).catch(() => {});
|
||
} catch { /* egal */ }
|
||
}
|
||
|
||
/** Meldet an. @returns {{requiresTotp:true}|{session:object}} */
|
||
export async function login(serverUrlInput, email, password, totpCode) {
|
||
const serverUrl = normalizeServerUrl(serverUrlInput);
|
||
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 lokale Daten entfernen. */
|
||
export async function logout() {
|
||
const session = await getSession();
|
||
if (session) { try { await apiCall('logout.php', { method: 'POST', body: {} }); } catch { /* Token läuft ohnehin ab */ } }
|
||
await clearSession();
|
||
}
|
||
|
||
/** Stammdaten (Abteilungen, Prioritäten, Mitarbeiter, Status, Tags) – 10 Minuten zwischengespeichert. */
|
||
export async function getMeta(force = false) {
|
||
const { meta } = await chrome.storage.local.get('meta');
|
||
if (!force && 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 || [], agents: data.agents || [], statuses: data.statuses || [], tags: data.tags || [],
|
||
};
|
||
await chrome.storage.local.set({ meta: fresh });
|
||
return fresh;
|
||
}
|
||
|
||
/** 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 });
|
||
}
|
||
|
||
/** Vergleicht zwei Versionsnummern ("2.0.0" gegen "2.1"): negativ, null oder positiv. Nicht-Zahlen zählen als 0. */
|
||
export function compareVersions(a, b) {
|
||
const pa = String(a).split(/[.+-]/).map((x) => parseInt(x, 10) || 0);
|
||
const pb = String(b).split(/[.+-]/).map((x) => parseInt(x, 10) || 0);
|
||
for (let i = 0; i < Math.max(pa.length, pb.length, 3); i++) {
|
||
const d = (pa[i] || 0) - (pb[i] || 0);
|
||
if (d !== 0) return d;
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
const UPDATE_CHECK_EVERY_MS = 6 * 3600 * 1000;
|
||
|
||
/**
|
||
* Fragt den Server, ob es eine neuere Version dieser Erweiterung gibt (die Plugin-Webseite liefert sie über DeskHub). Das Ergebnis steht
|
||
* in storage.local unter "update": { version, notes, size, checkedAt } oder null, wenn die Erweiterung aktuell ist. Höchstens alle 6 Stunden,
|
||
* außer mit force. Netzwerkfehler ändern nichts.
|
||
*/
|
||
export async function checkForExtensionUpdate({ force = false } = {}) {
|
||
const session = await getSession();
|
||
if (!session) return null;
|
||
const { update, updateCheckedAt = 0 } = await chrome.storage.local.get(['update', 'updateCheckedAt']);
|
||
if (!force && Date.now() - updateCheckedAt < UPDATE_CHECK_EVERY_MS) return update || null;
|
||
try {
|
||
const { data } = await apiCall('app_update.php', { query: { platform: 'chrome' } });
|
||
const current = chrome.runtime.getManifest().version;
|
||
const newer = data?.available && data.version && compareVersions(data.version, current) > 0;
|
||
const info = newer ? { version: String(data.version), notes: String(data.notes || ''), size: Number(data.size) || 0, checkedAt: Date.now() } : null;
|
||
await chrome.storage.local.set({ update: info, updateCheckedAt: Date.now() });
|
||
return info;
|
||
} catch {
|
||
return update || null; // Fehler: beim nächsten Mal erneut
|
||
}
|
||
}
|
||
|
||
/** Maskiert Text und macht Zeilenumbrüche zu <br>: 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(/>/g, '>').replace(/\r\n|\r|\n/g, '<br>');
|
||
}
|
||
|
||
export const truncate = (s, n) => (String(s).length > n ? String(s).slice(0, n - 1) + '…' : String(s));
|
||
|
||
/** „2026-10-31 04:03:00“ (Serverzeit) → Date. Der Server liefert Zeiten ohne Zeitzone; sie werden wie lokale Zeit behandelt. */
|
||
export function parseServerTime(s) {
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?/.exec(String(s || ''));
|
||
return m ? new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +(m[6] || 0)) : null;
|
||
}
|
||
|
||
/** Kurzform wie „vor 5 Min“, „gestern 14:02“, „31.10. 04:03“. */
|
||
export function timeAgo(s, now = new Date()) {
|
||
const d = parseServerTime(s);
|
||
if (!d) return '';
|
||
const diff = Math.round((now - d) / 1000);
|
||
const hm = d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||
if (diff < 0) { // Zukunft (z. B. Wiedervorlage)
|
||
const tomorrow = new Date(now); tomorrow.setDate(now.getDate() + 1);
|
||
if (d.toDateString() === now.toDateString()) return `heute ${hm}`;
|
||
if (d.toDateString() === tomorrow.toDateString()) return `morgen ${hm}`;
|
||
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}. ${hm}`;
|
||
}
|
||
if (diff < 60) return 'gerade eben';
|
||
if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min`;
|
||
const sameDay = d.toDateString() === now.toDateString();
|
||
if (sameDay) return `heute ${hm}`;
|
||
const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1);
|
||
if (d.toDateString() === yesterday.toDateString()) return `gestern ${hm}`;
|
||
return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}. ${hm}`;
|
||
}
|
||
|
||
export const fmtBytes = (n) => (n >= 1048576 ? (n / 1048576).toFixed(1) + ' MB' : n >= 1024 ? Math.round(n / 1024) + ' KB' : n + ' B');
|
||
export const initials = (name) => String(name || '?').trim().split(/\s+/).slice(0, 2).map((p) => p[0]?.toUpperCase() || '').join('') || '?';
|
||
export const safeColor = (c) => (typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : null);
|
||
|
||
export const RESOLUTION_REASONS = {
|
||
fixed: 'Fehler behoben', guidance: 'Anleitung oder Beratung', duplicate: 'Doppelt / zusammengeführt', not_reproducible: 'Nicht reproduzierbar',
|
||
no_action: 'Keine Maßnahme erforderlich', spam: 'Spam oder Sperre', other: 'Sonstiges',
|
||
};
|
||
export const STATUS_LABELS = { open: 'Offen', pending: 'Wartet auf Kunde', answered: 'Beantwortet', on_hold: 'Pausiert', resolved: 'Gelöst', closed: 'Geschlossen' };
|
||
export const AVAILABILITY = { available: 'Verfügbar', busy: 'Beschäftigt', break: 'Pause', offline: 'Offline' };
|