From 7c47e818f27d723f3f21e0031731e64d1b96ace0 Mon Sep 17 00:00:00 2001 From: fs Date: Mon, 20 Apr 2026 22:41:07 +0200 Subject: [PATCH] feat(js): harden global HTTP and module import contracts --- bin/js-contract-check.sh | 18 +-- .../web/js/pages/address-book-index.js | 12 +- .../web/js/components/app-bookmark-panel.js | 54 ++----- .../web/js/components/app-bookmark-save.js | 54 ++----- .../js/components/app-notification-bell.js | 33 ++--- .../Architecture/FrontendJsContractsTest.php | 136 +++++++++++++----- web/js/components/app-flash-auto-dismiss.js | 26 ++-- web/js/components/app-global-search.js | 11 +- web/js/components/app-page-editor.js | 18 +-- web/js/components/app-session-warning.js | 34 +---- web/js/components/app-tenant-switcher.js | 33 ++--- web/js/components/app-theme-toggle.js | 18 +-- 12 files changed, 181 insertions(+), 266 deletions(-) diff --git a/bin/js-contract-check.sh b/bin/js-contract-check.sh index 3affc04..3c56b83 100755 --- a/bin/js-contract-check.sh +++ b/bin/js-contract-check.sh @@ -21,20 +21,20 @@ fail() { failures=$((failures + 1)) } -alert_hits="$(rg -n "window\\.alert\\s*\\(" web/js modules/helpdesk/web/js -g '*.js' || true)" +alert_hits="$(rg -n "window\\.alert\\s*\\(" web/js modules/*/web/js -g '*.js' || true)" if [[ -n "${alert_hits}" ]]; then fail "window.alert usage detected:" echo "${alert_hits}" >&2 else - ok "No window.alert usage in web/js and modules/helpdesk/web/js" + ok "No window.alert usage in web/js and modules/*/web/js" fi -fetch_hits="$(rg -n "\\bfetch\\s*\\(" modules/helpdesk/web/js -g '*.js' || true)" +fetch_hits="$(rg -n "\\bfetch\\s*\\(" web/js modules/*/web/js -g '*.js' -g '!web/js/core/app-http.js' -g '!web/js/core/app-telemetry.js' || true)" if [[ -n "${fetch_hits}" ]]; then - fail "Direct fetch(...) usage detected in helpdesk JS:" + fail "Direct fetch(...) usage detected outside approved HTTP infrastructure:" echo "${fetch_hits}" >&2 else - ok "Helpdesk JS uses centralized app-http (no direct fetch)" + ok "Frontend JS uses centralized app-http (except app-http/app-telemetry infrastructure)" fi runtime_files=( @@ -83,14 +83,14 @@ while IFS= read -r line; do continue fi - echo "${file}: invalid import path -> ${import_path}" >&2 + echo "${file}: invalid module import path -> ${import_path}" >&2 import_violations=$((import_violations + 1)) -done < <(rg -n "^\\s*import\\s+(?:[^'\\\"]+\\s+from\\s+)?['\\\"][^'\\\"]+['\\\"]" modules/helpdesk/web/js -g '*.js') +done < <(rg -n "^\\s*import\\s+(?:[^'\\\"]+\\s+from\\s+)?['\\\"][^'\\\"]+['\\\"]" modules/*/web/js -g '*.js') if [[ "${import_violations}" -gt 0 ]]; then - fail "Helpdesk import policy violations: ${import_violations}" + fail "Module import policy violations: ${import_violations}" else - ok "Helpdesk import policy valid (core imports absolute /js/...; local imports relative)" + ok "Module import policy valid (core imports absolute /js/...; local imports relative)" fi if [[ "${failures}" -gt 0 ]]; then diff --git a/modules/addressbook/web/js/pages/address-book-index.js b/modules/addressbook/web/js/pages/address-book-index.js index e2ed6ee..0311b34 100644 --- a/modules/addressbook/web/js/pages/address-book-index.js +++ b/modules/addressbook/web/js/pages/address-book-index.js @@ -1,9 +1,9 @@ -import { initMultiSelect } from '../../../../js/components/app-multiselect-init.js'; -import { initMultiSelectCascade } from '../../../../js/components/app-multiselect-cascade.js'; -import { initStandardListPage } from '../../../../js/core/app-grid-factory.js'; -import { readPageConfig } from '../../../../js/core/app-page-config.js'; -import { warnOnce } from '../../../../js/core/app-dom.js'; -import { getAppBase, escapeHtml, buildBadgeList, gridFilterMultiCsv, gridFilterText, withCurrentListReturn } from '../../../../js/pages/app-list-utils.js'; +import { initMultiSelect } from '/js/components/app-multiselect-init.js'; +import { initMultiSelectCascade } from '/js/components/app-multiselect-cascade.js'; +import { initStandardListPage } from '/js/core/app-grid-factory.js'; +import { readPageConfig } from '/js/core/app-page-config.js'; +import { warnOnce } from '/js/core/app-dom.js'; +import { getAppBase, escapeHtml, buildBadgeList, gridFilterMultiCsv, gridFilterText, withCurrentListReturn } from '/js/pages/app-list-utils.js'; const config = readPageConfig('address-book-index'); if (config) { diff --git a/modules/bookmarks/web/js/components/app-bookmark-panel.js b/modules/bookmarks/web/js/components/app-bookmark-panel.js index f67adcd..45474c3 100644 --- a/modules/bookmarks/web/js/components/app-bookmark-panel.js +++ b/modules/bookmarks/web/js/components/app-bookmark-panel.js @@ -4,9 +4,10 @@ * - Reorder bookmarks inside groups (up/down) * - Edit/Delete groups and bookmarks with confirm dialog */ -import { showAsyncFlash } from '../../../../js/components/app-async-flash.js'; -import { warnOnce, resolveHost } from '../../../../js/core/app-dom.js'; -import { confirmDialog } from '../../../../js/core/app-confirm-dialog.js'; +import { showAsyncFlash } from '/js/components/app-async-flash.js'; +import { warnOnce, resolveHost } from '/js/core/app-dom.js'; +import { confirmDialog } from '/js/core/app-confirm-dialog.js'; +import { postForm } from '/js/core/app-http.js'; export function initBookmarkPanel(root = document, options = {}) { const host = resolveHost(root); @@ -84,14 +85,6 @@ export function initBookmarkPanel(root = document, options = {}) { }); }; - const parseJsonSafe = async (response) => { - try { - return await response.json(); - } catch { - return null; - } - }; - const reportError = (code, message, details = {}) => { warnOnce(code, message, { ...moduleContext, ...details }); showAsyncFlash('error', message); @@ -176,16 +169,7 @@ export function initBookmarkPanel(root = document, options = {}) { setPending(true); try { - const response = await fetch(reorderUrl, { - method: 'POST', - headers: { 'X-Requested-With': 'XMLHttpRequest' }, - body - }); - const json = await parseJsonSafe(response); - if (!response.ok || !json) { - reportError('FETCH_FAILED', messageReorderFailed, { status: response.status, type }); - return; - } + const json = await postForm(reorderUrl, body); if (!json.ok) { reportError('FETCH_FAILED', messageReorderFailed, { error: json.error, type }); return; @@ -193,7 +177,7 @@ export function initBookmarkPanel(root = document, options = {}) { runReload(); } catch (error) { - reportError('FETCH_ERROR', messageReorderFailed, { error, type }); + reportError('FETCH_ERROR', messageReorderFailed, { error, status: error?.status, type }); } finally { setPending(false); } @@ -317,16 +301,7 @@ export function initBookmarkPanel(root = document, options = {}) { setPending(true); try { - const response = await fetch(groupDeleteUrl, { - method: 'POST', - headers: { 'X-Requested-With': 'XMLHttpRequest' }, - body - }); - const json = await parseJsonSafe(response); - if (!response.ok || !json) { - reportError('FETCH_FAILED', messageGroupDeleteFailed, { status: response.status, groupId }); - return; - } + const json = await postForm(groupDeleteUrl, body); if (!json.ok) { reportError('FETCH_FAILED', messageGroupDeleteFailed, { error: json.error, groupId }); return; @@ -334,7 +309,7 @@ export function initBookmarkPanel(root = document, options = {}) { runReload(messageGroupDeleted); } catch (error) { - reportError('FETCH_ERROR', messageGroupDeleteFailed, { error, groupId }); + reportError('FETCH_ERROR', messageGroupDeleteFailed, { error, status: error?.status, groupId }); } finally { setPending(false); } @@ -368,16 +343,7 @@ export function initBookmarkPanel(root = document, options = {}) { setPending(true); try { - const response = await fetch(bookmarkDeleteUrl, { - method: 'POST', - headers: { 'X-Requested-With': 'XMLHttpRequest' }, - body - }); - const json = await parseJsonSafe(response); - if (!response.ok || !json) { - reportError('FETCH_FAILED', messageBookmarkDeleteFailed, { status: response.status, bookmarkId }); - return; - } + const json = await postForm(bookmarkDeleteUrl, body); if (!json.ok) { reportError('FETCH_FAILED', messageBookmarkDeleteFailed, { error: json.error, bookmarkId }); return; @@ -385,7 +351,7 @@ export function initBookmarkPanel(root = document, options = {}) { runReload(messageBookmarkDeleted); } catch (error) { - reportError('FETCH_ERROR', messageBookmarkDeleteFailed, { error, bookmarkId }); + reportError('FETCH_ERROR', messageBookmarkDeleteFailed, { error, status: error?.status, bookmarkId }); } finally { setPending(false); } diff --git a/modules/bookmarks/web/js/components/app-bookmark-save.js b/modules/bookmarks/web/js/components/app-bookmark-save.js index fc75506..bc7651d 100644 --- a/modules/bookmarks/web/js/components/app-bookmark-save.js +++ b/modules/bookmarks/web/js/components/app-bookmark-save.js @@ -3,9 +3,10 @@ * Bookmark dialog (create/update) + group dialog (create/update). */ -import { showAsyncFlash } from '../../../../js/components/app-async-flash.js'; -import { warnOnce, resolveHost } from '../../../../js/core/app-dom.js'; -import { getAppBase } from '../../../../js/pages/app-list-utils.js'; +import { showAsyncFlash } from '/js/components/app-async-flash.js'; +import { warnOnce, resolveHost } from '/js/core/app-dom.js'; +import { postForm } from '/js/core/app-http.js'; +import { getAppBase } from '/js/pages/app-list-utils.js'; export function initBookmarkSave(root = document, options = {}) { const host = resolveHost(root); @@ -133,14 +134,6 @@ export function initBookmarkSave(root = document, options = {}) { showAsyncFlash('error', message); }; - const parseJsonSafe = async (response) => { - try { - return await response.json(); - } catch { - return null; - } - }; - const resolveBookmarkApiErrorMessage = (errorCode) => { if (!errorCode) return bookmarkMessageErrorGeneric; return bookmarkApiErrorMessages[errorCode] || bookmarkMessageErrorGeneric; @@ -348,16 +341,7 @@ export function initBookmarkSave(root = document, options = {}) { if (editingBookmarkId) { body.set('id', editingBookmarkId); try { - const response = await fetch(getAppBase() + 'bookmarks/update-data', { - method: 'POST', - headers: { 'X-Requested-With': 'XMLHttpRequest' }, - body - }); - const json = await parseJsonSafe(response); - if (!response.ok || !json) { - reportError('FETCH_FAILED', bookmarkMessageErrorGeneric, { status: response.status }); - return; - } + const json = await postForm(getAppBase() + 'bookmarks/update-data', body); if (!json.ok) { reportError('FETCH_FAILED', resolveBookmarkApiErrorMessage(json.error), { error: json.error }); return; @@ -366,7 +350,7 @@ export function initBookmarkSave(root = document, options = {}) { closeBookmarkDialog(); runReloadWithSuccess(bookmarkMessageUpdated); } catch (error) { - reportError('FETCH_ERROR', bookmarkMessageErrorGeneric, { error }); + reportError('FETCH_ERROR', bookmarkMessageErrorGeneric, { error, status: error?.status }); } finally { setBookmarkSubmitPending(false); } @@ -375,16 +359,7 @@ export function initBookmarkSave(root = document, options = {}) { body.set('url', currentRelativeUrl()); try { - const response = await fetch(getAppBase() + 'bookmarks/save-data', { - method: 'POST', - headers: { 'X-Requested-With': 'XMLHttpRequest' }, - body - }); - const json = await parseJsonSafe(response); - if (!response.ok || !json) { - reportError('FETCH_FAILED', bookmarkMessageErrorGeneric, { status: response.status }); - return; - } + const json = await postForm(getAppBase() + 'bookmarks/save-data', body); if (!json.ok) { if (json.error === 'max_reached') { showAsyncFlash('warning', maxBookmarkMessage || 'Maximum bookmarks reached'); @@ -397,7 +372,7 @@ export function initBookmarkSave(root = document, options = {}) { closeBookmarkDialog(); runReloadWithSuccess(json.mode === 'updated' ? bookmarkMessageUpdated : bookmarkMessageSaved); } catch (error) { - reportError('FETCH_ERROR', bookmarkMessageErrorGeneric, { error }); + reportError('FETCH_ERROR', bookmarkMessageErrorGeneric, { error, status: error?.status }); } finally { setBookmarkSubmitPending(false); } @@ -659,16 +634,7 @@ export function initBookmarkSave(root = document, options = {}) { setGroupSubmitPending(true); try { - const response = await fetch(getAppBase() + 'bookmarks/group-save-data', { - method: 'POST', - headers: { 'X-Requested-With': 'XMLHttpRequest' }, - body - }); - const json = await parseJsonSafe(response); - if (!response.ok || !json) { - reportError('FETCH_FAILED', groupMessageErrorGeneric, { status: response.status }); - return; - } + const json = await postForm(getAppBase() + 'bookmarks/group-save-data', body); if (!json.ok) { if (json.error === 'max_reached') { showAsyncFlash('warning', maxGroupMessage); @@ -694,7 +660,7 @@ export function initBookmarkSave(root = document, options = {}) { runReloadWithSuccess(successMessage); } } catch (error) { - reportError('FETCH_ERROR', groupMessageErrorGeneric, { error }); + reportError('FETCH_ERROR', groupMessageErrorGeneric, { error, status: error?.status }); } finally { setGroupSubmitPending(false); } diff --git a/modules/notifications/web/js/components/app-notification-bell.js b/modules/notifications/web/js/components/app-notification-bell.js index ad14dbb..5c4bd75 100644 --- a/modules/notifications/web/js/components/app-notification-bell.js +++ b/modules/notifications/web/js/components/app-notification-bell.js @@ -5,8 +5,9 @@ * notification list rendering, mark-read and dismiss actions. */ -import { telemetry } from '../../../../js/core/app-telemetry.js'; -import { getAppBase } from '../../../../js/pages/app-list-utils.js'; +import { telemetry } from '/js/core/app-telemetry.js'; +import { getJson, postForm } from '/js/core/app-http.js'; +import { getAppBase } from '/js/pages/app-list-utils.js'; const POLL_INTERVAL_MS = 60_000; const TYPE_ICONS = { @@ -43,12 +44,6 @@ function escapeHtml(str) { return d.innerHTML; } -async function fetchJson(url, options = {}) { - const resp = await fetch(url, options); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - return resp.json(); -} - export function initNotificationBell(root = document, config = {}) { const details = root.querySelector('[data-notification-bell]'); const dropdown = root.querySelector('[data-notification-dropdown]'); @@ -173,7 +168,7 @@ export function initNotificationBell(root = document, config = {}) { showState('loading'); list?.querySelectorAll('.app-notification-item').forEach(el => el.remove()); try { - const data = await fetchJson(endpoint('notifications/list-data'), { signal }); + const data = await getJson(endpoint('notifications/list-data'), { signal }); if (data.ok) { renderNotifications(data.notifications || []); updateBadge(data.unread_count ?? 0); @@ -192,7 +187,7 @@ export function initNotificationBell(root = document, config = {}) { async function pollUnreadCount() { try { - const data = await fetchJson(endpoint('notifications/unread-count-data'), { signal }); + const data = await getJson(endpoint('notifications/unread-count-data'), { signal }); if (data.ok) { updateBadge(data.unread_count ?? 0); } @@ -212,11 +207,7 @@ export function initNotificationBell(root = document, config = {}) { body.set('id', String(id)); if (csrf.key) body.set(csrf.key, csrf.token); - const data = await fetchJson(endpoint('notifications/mark-read-data'), { - method: 'POST', - body, - signal, - }); + const data = await postForm(endpoint('notifications/mark-read-data'), body, { signal }); if (data.ok) { updateBadge(data.unread_count ?? 0); if (details.open) loadNotifications(); @@ -237,11 +228,7 @@ export function initNotificationBell(root = document, config = {}) { body.set('all', '1'); if (csrf.key) body.set(csrf.key, csrf.token); - const data = await fetchJson(endpoint('notifications/mark-read-data'), { - method: 'POST', - body, - signal, - }); + const data = await postForm(endpoint('notifications/mark-read-data'), body, { signal }); if (data.ok) { updateBadge(data.unread_count ?? 0); if (details.open) loadNotifications(); @@ -262,11 +249,7 @@ export function initNotificationBell(root = document, config = {}) { body.set('id', String(id)); if (csrf.key) body.set(csrf.key, csrf.token); - const data = await fetchJson(endpoint('notifications/delete-data'), { - method: 'POST', - body, - signal, - }); + const data = await postForm(endpoint('notifications/delete-data'), body, { signal }); if (data.ok) { updateBadge(data.unread_count ?? 0); const el = list?.querySelector(`[data-notification-id="${id}"]`); diff --git a/tests/Architecture/FrontendJsContractsTest.php b/tests/Architecture/FrontendJsContractsTest.php index 84bbef2..02dd21b 100644 --- a/tests/Architecture/FrontendJsContractsTest.php +++ b/tests/Architecture/FrontendJsContractsTest.php @@ -8,17 +8,17 @@ final class FrontendJsContractsTest extends TestCase { use ProjectFileAssertionSupport; - public function testNoWindowAlertCallsInCoreAndHelpdeskJs(): void + public function testNoWindowAlertCallsInFrontendJsScope(): void { - $violations = array_merge( - $this->findPatternMatchesInFiles('web/js', '/window\.alert\s*\(/', ['js']), - $this->findPatternMatchesInFiles('modules/helpdesk/web/js', '/window\.alert\s*\(/', ['js']) - ); + $violations = $this->findPatternMatchesInFiles('web/js', '/window\.alert\s*\(/', ['js']); + foreach ($this->moduleJsDirectories() as $moduleJsDir) { + $violations = array_merge($violations, $this->findPatternMatchesInFiles($moduleJsDir, '/window\.alert\s*\(/', ['js'])); + } $violations = array_values(array_unique($violations)); sort($violations); - $this->assertSame([], $violations, "window.alert usage found in JS scope:\n" . implode("\n", $violations)); + $this->assertSame([], $violations, "window.alert usage found in frontend JS scope:\n" . implode("\n", $violations)); } public function testHelpdeskRuntimeComponentsDoNotAutoInitialize(): void @@ -39,53 +39,55 @@ final class FrontendJsContractsTest extends TestCase } } - public function testHelpdeskJsUsesCentralHttpLayer(): void + public function testFrontendJsUsesCentralHttpLayer(): void { - $violations = $this->findPatternMatchesInFiles('modules/helpdesk/web/js', '/\bfetch\s*\(/', ['js']); - $this->assertSame([], $violations, "Direct fetch(...) found in helpdesk JS:\n" . implode("\n", $violations)); + $violations = $this->findDirectFetchCallsInFrontendScope(); + $this->assertSame([], $violations, "Direct fetch(...) found outside approved HTTP infrastructure:\n" . implode("\n", $violations)); } - public function testHelpdeskImportPolicyForCoreAndSiblingModules(): void + public function testModuleImportPolicyForCoreAndSiblingModules(): void { $root = $this->projectRootPath(); - $base = $root . '/modules/helpdesk/web/js'; - $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS)); - $violations = []; - /** @var \SplFileInfo $file */ - foreach ($iterator as $file) { - if (!$file->isFile() || $file->getExtension() !== 'js') { - continue; - } + foreach ($this->moduleJsDirectories() as $moduleJsDir) { + $base = $root . '/' . $moduleJsDir; + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS)); - $content = file_get_contents($file->getPathname()); - $this->assertNotFalse($content, 'Could not read file: ' . $file->getPathname()); - $relativePath = str_replace($root . '/', '', $file->getPathname()); - - preg_match_all('/^\s*import\s+(?:[^\"\']+\s+from\s+)?[\"\']([^\"\']+)[\"\']/m', $content, $matches); - foreach ($matches[1] as $importPathRaw) { - $importPath = (string) $importPathRaw; - - if (str_starts_with($importPath, '/js/')) { + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if (!$file->isFile() || $file->getExtension() !== 'js') { continue; } - if (str_starts_with($importPath, './') || str_starts_with($importPath, '../')) { - if (preg_match('#(?:^|/)js/(core|components|pages)/#', $importPath) === 1) { - $violations[] = $relativePath . ' => relative core import: ' . $importPath; + $content = file_get_contents($file->getPathname()); + $this->assertNotFalse($content, 'Could not read file: ' . $file->getPathname()); + $relativePath = str_replace($root . '/', '', $file->getPathname()); + + preg_match_all('/^\s*import\s+(?:[^\"\']+\s+from\s+)?[\"\']([^\"\']+)[\"\']/m', $content, $matches); + foreach ($matches[1] as $importPathRaw) { + $importPath = (string) $importPathRaw; + + if (str_starts_with($importPath, '/js/')) { + continue; } - continue; - } - $violations[] = $relativePath . ' => invalid import path: ' . $importPath; + if (str_starts_with($importPath, './') || str_starts_with($importPath, '../')) { + if (preg_match('#(?:^|/)js/(core|components|pages)/#', $importPath) === 1) { + $violations[] = $relativePath . ' => relative core import: ' . $importPath; + } + continue; + } + + $violations[] = $relativePath . ' => invalid import path: ' . $importPath; + } } } $violations = array_values(array_unique($violations)); sort($violations); - $this->assertSame([], $violations, "Helpdesk import policy violations:\n" . implode("\n", $violations)); + $this->assertSame([], $violations, "Module import policy violations:\n" . implode("\n", $violations)); } public function testLookupFieldComponentExportsRuntimeInitializerWithoutAutoInit(): void @@ -113,4 +115,70 @@ final class FrontendJsContractsTest extends TestCase 'modules/helpdesk/web/js/handover-schema-editor.js', ]; } + + /** + * @return list + */ + private function moduleJsDirectories(): array + { + $root = $this->projectRootPath(); + $modulesRoot = $root . '/modules'; + $this->assertDirectoryExists($modulesRoot, 'Modules root not found.'); + + $dirs = []; + $entries = scandir($modulesRoot); + $this->assertNotFalse($entries, 'Could not read modules directory.'); + foreach ($entries as $entry) { + if ($entry === '.' || $entry === '..') { + continue; + } + $jsPath = $modulesRoot . '/' . $entry . '/web/js'; + if (is_dir($jsPath)) { + $dirs[] = 'modules/' . $entry . '/web/js'; + } + } + + sort($dirs); + return $dirs; + } + + /** + * @return list + */ + private function findDirectFetchCallsInFrontendScope(): array + { + $root = $this->projectRootPath(); + $allowed = [ + 'web/js/core/app-http.js', + 'web/js/core/app-telemetry.js', + ]; + $scanRoots = array_merge(['web/js'], $this->moduleJsDirectories()); + $violations = []; + + foreach ($scanRoots as $scanRoot) { + $base = $root . '/' . $scanRoot; + $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS)); + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if (!$file->isFile() || $file->getExtension() !== 'js') { + continue; + } + + $relativePath = str_replace($root . '/', '', $file->getPathname()); + if (in_array($relativePath, $allowed, true)) { + continue; + } + + $content = file_get_contents($file->getPathname()); + $this->assertNotFalse($content, 'Could not read file: ' . $file->getPathname()); + if (preg_match('/\bfetch\s*\(/', $content) === 1) { + $violations[] = $relativePath; + } + } + } + + $violations = array_values(array_unique($violations)); + sort($violations); + return $violations; + } } diff --git a/web/js/components/app-flash-auto-dismiss.js b/web/js/components/app-flash-auto-dismiss.js index 2e5af85..11c5e03 100644 --- a/web/js/components/app-flash-auto-dismiss.js +++ b/web/js/components/app-flash-auto-dismiss.js @@ -3,6 +3,7 @@ * Supports slide-out animation and hover-pause. */ import { resolveHost } from '../core/app-dom.js'; +import { postForm as postFormRequest } from '../core/app-http.js'; export function initFlashAutoDismiss(root = document, options = {}) { const { @@ -32,17 +33,14 @@ export function initFlashAutoDismiss(root = document, options = {}) { const postForm = async (form) => { const action = form.getAttribute('action'); if (!action) { - return null; + return false; + } + try { + await postFormRequest(action, new FormData(form)); + return true; + } catch { + return false; } - const body = new URLSearchParams(new FormData(form)); - return fetch(action, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'X-Requested-With': 'fetch', - }, - body, - }); }; notices.forEach((notice) => { @@ -62,8 +60,8 @@ export function initFlashAutoDismiss(root = document, options = {}) { } const form = notice.querySelector('form'); if (form) { - const response = await postForm(form); - if (!response || !response.ok) { + const ok = await postForm(form); + if (!ok) { return; } } @@ -80,8 +78,8 @@ export function initFlashAutoDismiss(root = document, options = {}) { if (destroyed) return; const form = notice.querySelector('form'); if (form) { - const response = await postForm(form); - if (!response || !response.ok) return; + const ok = await postForm(form); + if (!ok) return; } dismissNotice(notice); }, 1000); diff --git a/web/js/components/app-global-search.js b/web/js/components/app-global-search.js index 7c19693..5c2cf7c 100644 --- a/web/js/components/app-global-search.js +++ b/web/js/components/app-global-search.js @@ -5,6 +5,7 @@ * preview API and renders them grouped by category with keyboard navigation. */ import { warnOnce, resolveHost } from '../core/app-dom.js'; +import { getJson } from '../core/app-http.js'; import { isEditableTarget } from '../core/app-form-utils.js'; const ICON_MAP = { @@ -221,13 +222,7 @@ export function initGlobalSearch(root = document, options = {}) { url.searchParams.set('q', value); try { - const response = await fetch(url.toString(), { headers: { 'Accept': 'application/json' }, signal }); - if (!response.ok) { - warnOnce('FETCH_FAILED', `Global search failed: ${response.status} ${response.statusText}`, { module: 'global-search' }); - setState('error'); - return; - } - const data = await response.json(); + const data = await getJson(url.toString(), { signal }); if (Array.isArray(data)) { render(data, value); } else { @@ -236,7 +231,7 @@ export function initGlobalSearch(root = document, options = {}) { } } catch (err) { if (err.name === 'AbortError') {return;} - warnOnce('FETCH_ERROR', 'Global search fetch error', { module: 'global-search', err }); + warnOnce('FETCH_ERROR', 'Global search fetch error', { module: 'global-search', err, status: err?.status }); setState('error'); } }; diff --git a/web/js/components/app-page-editor.js b/web/js/components/app-page-editor.js index 889943d..2e0e621 100644 --- a/web/js/components/app-page-editor.js +++ b/web/js/components/app-page-editor.js @@ -2,6 +2,7 @@ * Editor.js integration — lifecycle-managed page editor module. */ import EditorJS from '../../vendor/editorjs/editorjs.mjs'; +import { postForm } from '../core/app-http.js'; import { warnOnce, resolveHost } from '../core/app-dom.js'; const parseData = (value) => { @@ -194,21 +195,8 @@ export function initPageEditor(root = document, config = {}) { form.dataset.submitting = '1'; try { - const formData = new FormData(form); - const response = await fetch(form.action || window.location.pathname, { - method: 'POST', - headers: { - 'X-Requested-With': 'XMLHttpRequest', - Accept: 'application/json', - }, - body: formData, - }); - if (response.redirected) { - window.location.href = response.url; - return; - } - const data = await response.json().catch(() => null); - if (data && data.redirect) { + const data = await postForm(form.action || window.location.pathname, new FormData(form)); + if (data && typeof data === 'object' && typeof data.redirect === 'string' && data.redirect.trim() !== '') { window.location.href = data.redirect; return; } diff --git a/web/js/components/app-session-warning.js b/web/js/components/app-session-warning.js index 5887294..743ee7f 100644 --- a/web/js/components/app-session-warning.js +++ b/web/js/components/app-session-warning.js @@ -2,6 +2,7 @@ * Session expiry warning dialog. */ import { warnOnce, resolveHost } from '../core/app-dom.js'; +import { postForm } from '../core/app-http.js'; const WARNING_LEAD_SECONDS = 120; const TICK_INTERVAL_MS = 1000; @@ -211,24 +212,9 @@ export function initSessionWarning(root = document, runtimeConfig = {}) { extendButton.disabled = true; try { - const body = new URLSearchParams({ + const data = await postForm(config.pingUrl, { [config.csrfKey]: config.csrfToken, }); - - const response = await fetch(config.pingUrl, { - method: 'POST', - headers: { - 'X-Requested-With': 'XMLHttpRequest', - }, - body, - }); - - if (!response.ok) { - window.location.reload(); - return; - } - - const data = await response.json(); if (typeof data.idle_timeout_seconds === 'number' && data.idle_timeout_seconds > 0) { config.idleSeconds = data.idle_timeout_seconds; } @@ -267,23 +253,9 @@ export function initSessionWarning(root = document, runtimeConfig = {}) { hasInteractionSinceLastSync = false; try { - const body = new URLSearchParams({ + const data = await postForm(config.pingUrl, { [config.csrfKey]: config.csrfToken, }); - - const response = await fetch(config.pingUrl, { - method: 'POST', - headers: { - 'X-Requested-With': 'XMLHttpRequest', - }, - body, - }); - - if (!response.ok) { - return; - } - - const data = await response.json(); if (typeof data.idle_timeout_seconds === 'number' && data.idle_timeout_seconds > 0) { config.idleSeconds = data.idle_timeout_seconds; } diff --git a/web/js/components/app-tenant-switcher.js b/web/js/components/app-tenant-switcher.js index 147d93c..056301f 100644 --- a/web/js/components/app-tenant-switcher.js +++ b/web/js/components/app-tenant-switcher.js @@ -3,6 +3,7 @@ */ import { showAsyncFlash } from './app-async-flash.js'; import { warnOnce, resolveHost } from '../core/app-dom.js'; +import { postForm } from '../core/app-http.js'; const SWITCHER_ROOT_SELECTOR = '[data-tenant-switcher]'; const TENANT_LINK_SELECTOR = '[data-switch-tenant]'; @@ -32,40 +33,22 @@ const switchTenant = async (link) => { setPending(link, true); try { - const body = new URLSearchParams({ + const data = await postForm('admin/users/switch-tenant', { tenant_id: tenantId, [csrfKey]: csrfToken }); - - const response = await fetch('admin/users/switch-tenant', { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - 'X-Requested-With': 'fetch' - }, - body - }); - - if (response.ok) { - const data = await response.json(); - if (data.ok) { - if (data.theme) { - document.documentElement.dataset.theme = data.theme; - } - window.location.reload(); - } else { - warnOnce('FETCH_FAILED', 'Tenant switch failed', { module: 'tenant-switcher', error: data.error }); - showAsyncFlash('error', errorMessage); - setPending(link, false); + if (data && data.ok) { + if (data.theme) { + document.documentElement.dataset.theme = data.theme; } + window.location.reload(); } else { - warnOnce('FETCH_FAILED', `Tenant switch server error: ${response.status}`, { module: 'tenant-switcher' }); + warnOnce('FETCH_FAILED', 'Tenant switch failed', { module: 'tenant-switcher', error: data?.error ?? '' }); showAsyncFlash('error', errorMessage); setPending(link, false); } } catch (error) { - warnOnce('FETCH_ERROR', 'Tenant switch network error', { module: 'tenant-switcher', error }); + warnOnce('FETCH_ERROR', 'Tenant switch request failed', { module: 'tenant-switcher', error, status: error?.status }); showAsyncFlash('error', errorMessage); setPending(link, false); } diff --git a/web/js/components/app-theme-toggle.js b/web/js/components/app-theme-toggle.js index d39b889..c6b401a 100644 --- a/web/js/components/app-theme-toggle.js +++ b/web/js/components/app-theme-toggle.js @@ -2,6 +2,7 @@ * Theme switcher — light/dark toggle and menu with server-side persistence. */ import { warnOnce, resolveHost } from '../core/app-dom.js'; +import { postForm } from '../core/app-http.js'; const setTheme = (theme) => { document.documentElement.dataset.theme = theme; @@ -24,17 +25,12 @@ const updateTheme = async (source, theme) => { if (!url || !csrfKey || !csrfToken) { return true; } - const body = new URLSearchParams({ theme, [csrfKey]: csrfToken }); - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'Accept': 'application/json', - 'X-Requested-With': 'fetch', - }, - body, - }); - return response.ok; + try { + await postForm(url, { theme, [csrfKey]: csrfToken }); + return true; + } catch { + return false; + } }; export function initThemeControls(root = document, options = {}) {