From a6ac4510cdf367ea62f57a82ba6da837e7269db6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 23:30:29 +0000 Subject: [PATCH] Chat-Baustein: Option requestHeaders fuer Hochladen und Entfernen (Ticketsystem mit JWT) Co-Authored-By: Claude Opus 5.5 --- public/chat-composer.js | 29 +++++++++++- test/ui/composer-headers.ui.mjs | 84 +++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 test/ui/composer-headers.ui.mjs diff --git a/public/chat-composer.js b/public/chat-composer.js index eadf7e8..b90d7af 100644 --- a/public/chat-composer.js +++ b/public/chat-composer.js @@ -535,7 +535,9 @@ /** * Eingabezeile mit Anhaengen und Sprachaufnahme. * options: root, dropZone, uploadUrl (POST roh, DELETE uploadUrl/id), send({ text, attachmentIds }) -> message, - * onSent(message), placeholder, placeholderShort (schmale Bildschirme), disabled, disabledText + * onSent(message), placeholder, placeholderShort (schmale Bildschirme), disabled, disabledText, + * requestHeaders() -> Objekt oder Promise (zusaetzliche Header fuer Hochladen und Entfernen, z. B. das + * Bearer-JWT des Ticketsystems; ohne die Option wie bisher nur das Cookie) */ function create(options) { const opts = { placeholder: 'Nachricht schreiben…', disabled: false, disabledText: '', onSent() {}, ...options } @@ -708,9 +710,15 @@ return item } + function extraHeaders() { + return Promise.resolve(opts.requestHeaders ? opts.requestHeaders() : {}).then((headers) => headers || {}) + } + function deleteOnServer(item) { if (!item.meta || !item.meta.id) return - fetch(`${opts.uploadUrl}/${encodeURIComponent(item.meta.id)}`, { method: 'DELETE', credentials: 'same-origin' }).catch(() => {}) + extraHeaders() + .then((headers) => fetch(`${opts.uploadUrl}/${encodeURIComponent(item.meta.id)}`, { method: 'DELETE', credentials: 'same-origin', headers })) + .catch(() => {}) } function dropItem(item) { @@ -743,9 +751,26 @@ item.status = 'uploading' item.progress = 0 item.error = '' + renderTray() + extraHeaders().then( + (headers) => { + // Waehrend des Wartens entfernt oder Eingabezeile weg: nicht mehr hochladen + if (destroyed || !items.includes(item)) return + sendUpload(item, headers) + }, + (err) => { + item.status = 'error' + item.error = (err && err.message) || 'Anmeldung abgelaufen. Bitte Seite neu laden.' + afterUpload(item) + } + ) + } + + function sendUpload(item, headers) { const xhr = new XMLHttpRequest() item.xhr = xhr xhr.open('POST', opts.uploadUrl) + for (const [name, value] of Object.entries(headers)) xhr.setRequestHeader(name, value) xhr.setRequestHeader('Content-Type', 'application/octet-stream') xhr.setRequestHeader('X-File-Name', encodeURIComponent(item.name)) if (item.hint) xhr.setRequestHeader('X-Attachment-Kind', item.hint) diff --git a/test/ui/composer-headers.ui.mjs b/test/ui/composer-headers.ui.mjs new file mode 100644 index 0000000..977351b --- /dev/null +++ b/test/ui/composer-headers.ui.mjs @@ -0,0 +1,84 @@ +// Playwright-Checks Chat-Baustein: Option requestHeaders (Ticketsystem mit JWT) fuer Hochladen und Entfernen. +// Nicht Teil von npm test. Aufruf: node test/ui/composer-headers.ui.mjs +import http from 'node:http' +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs' + +const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public') +// Testseite: ?mode=ok (async Header), none (ohne Option), fail (Header verworfen) +const PAGE = ` +
` + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, 'http://x') + if (url.pathname === '/composer-test.html') { + res.writeHead(200, { 'content-type': 'text/html' }) + return res.end(PAGE) + } + try { + const file = path.join(pub, url.pathname) + const data = await readFile(file) + res.writeHead(200, { 'content-type': file.endsWith('.js') ? 'text/javascript' : 'text/css' }) + res.end(data) + } catch { + res.writeHead(404) + res.end() + } +}) +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) +const base = `http://127.0.0.1:${server.address().port}` + +let failed = 0 +const check = (name, ok, detail = '') => { + console.log(`${ok ? 'OK ' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`) + if (!ok) failed += 1 +} + +const browser = await chromium.launch() +try { + const run = async (mode) => { + const page = await browser.newPage() + page.setDefaultTimeout(30000) + const calls = [] + await page.route('**/upload**', (route) => { + const req = route.request() + calls.push({ method: req.method(), path: new URL(req.url()).pathname, auth: req.headers().authorization || null }) + if (req.method() === 'DELETE') return route.fulfill({ status: 204, body: '' }) + return route.fulfill({ status: 201, contentType: 'application/json', body: JSON.stringify({ attachment: { id: 'a1', name: 'notiz.txt', type: 'text/plain', size: 5, kind: 'file' } }) }) + }) + await page.goto(`${base}/composer-test.html?mode=${mode}`) + await page.locator('#root input[type=file]:not([accept])').setInputFiles({ name: 'notiz.txt', mimeType: 'text/plain', buffer: Buffer.from('hallo') }) + await page.waitForTimeout(800) + return { page, calls } + } + + const ok = await run('ok') + const up = ok.calls.find((c) => c.method === 'POST') + check('mit_header_upload', up?.auth === 'Bearer test', JSON.stringify(up)) + await ok.page.click('.chat-tray-remove') + await ok.page.waitForTimeout(500) + const del = ok.calls.find((c) => c.method === 'DELETE') + check('mit_header_delete', del?.path === '/upload/a1' && del?.auth === 'Bearer test', JSON.stringify(del)) + + const none = await run('none') + const up2 = none.calls.find((c) => c.method === 'POST') + check('ohne_option_kein_header', Boolean(up2) && up2.auth === null, JSON.stringify(up2)) + + const fail = await run('fail') + const text = await fail.page.textContent('#root') + check('header_fehler', fail.calls.length === 0 && text.includes('JWT weg'), `${fail.calls.length} Aufrufe, Text: ${text.slice(0, 120)}`) +} finally { + await browser.close() + server.close() +} +console.log(failed ? `${failed} Check(s) fehlgeschlagen` : 'alle Checks ok') +process.exit(failed ? 1 : 0)