From 20c485be0c498ecc6d695d10293150ddab1bc05f Mon Sep 17 00:00:00 2001 From: "Sebastian BURGIN-FIX (ext)" Date: Thu, 22 Oct 2026 22:14:47 +0200 Subject: [PATCH] feat: feedback page redesign, screenshot first, technical tab with copy - Feedback tab: the screenshot first, the sender's message (name, email, comment, page, time) as a sticky card over it, hideable. - Technical tab: environment, console (errors and warnings highlighted, stack traces), failed requests and custom data, a copy button on every value and entry, Copy report as plain text, error count on the tab. - One inline script for tabs and copy buttons, allowed by the per-request nonce; still nothing from other hosts. Light and dark, mobile layout. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + README.md | 4 +- .../Controllers/FeedbackPageController.php | 111 +++- e2e/feedback.e2e.ts | 29 + resources/views/feedback/show.blade.php | 519 +++++++++++++----- tests/Feature/FeedbackLinkTest.php | 26 +- 6 files changed, 526 insertions(+), 164 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38be452..2687e10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the API and the widget are documented here. The format fo ### Added +- Feedback page redesign: screenshot first with the sender's message as a sticky, hideable card; technical details on a second tab with copy buttons for every value and log entry and "Copy report" as plain text; console errors highlighted and counted on the tab. - Feedback pages behind signed, expiring links with all details, console and network log and the screenshot (inline and download). - `feedback:link {id}` creates team links that can complete and reopen the entry (`--by` is logged, `--read-only`, `--expires`). - The API returns a read-only share link after sending (`FEEDBACK_SHARE_LINK_MINUTES`, `0` turns it off); the widget shows it on the thank you screen with a copy button, `after_send` receives it as `url`. diff --git a/README.md b/README.md index a856a12..c987446 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,7 @@ Changing commands accept `--by="Name"`, which is recorded in the activity log. ## Feedback pages and share links -Every entry has a page with the message, sender, page URL, browser, system, viewport, console and network log, custom data and the screenshot (inline, full size and as download). The page is reached through a **signed, expiring link**: no account, no admin panel, nothing to install for whoever receives it. +Every entry has a page built for fixing it. The **Feedback** tab shows the screenshot first, with the sender's message floating over it: name, email, comment, page and time, sticky while you scroll and hideable with one click. The **Technical** tab lists the environment (browser, system, viewport, screen, language, IP, user agent …), the console with errors and warnings highlighted and stack traces, failed requests and custom data. Every value and log entry has a copy button, **Copy report** puts everything into the clipboard as plain text for a ticket. Download and full size are one click away, team links add **Mark as completed** / **Reopen**. The page is reached through a **signed, expiring link**: no account, no admin panel, nothing to install for whoever receives it. **Team links** come from the CLI and can complete and reopen the entry: @@ -357,7 +357,7 @@ Event::listen(function (FeedbackReceived $event): void { }); ``` -**Security.** Links are signed with `APP_KEY`; changing any part (ID, expiry, `manage`, `by`) invalidates them. Invalid and expired links get the same empty `403` as every other unknown URL. Anyone holding a link can use it until it expires, so pick the expiry to match where you share it. Rotating `APP_KEY` revokes all links at once. Pages are `noindex`, `no-store`, `no-referrer`, run no JavaScript and load nothing from other hosts. The link host comes from `APP_URL` (CLI) or the request (widget), so set `APP_URL` to the public URL of the API. +**Security.** Links are signed with `APP_KEY`; changing any part (ID, expiry, `manage`, `by`) invalidates them. Invalid and expired links get the same empty `403` as every other unknown URL. Anyone holding a link can use it until it expires, so pick the expiry to match where you share it. Rotating `APP_KEY` revokes all links at once. Pages are `noindex`, `no-store`, `no-referrer`, run only one inline script for tabs and copy buttons (allowed by a per-request nonce) and load nothing from other hosts. The link host comes from `APP_URL` (CLI) or the request (widget), so set `APP_URL` to the public URL of the API. ## Configuration diff --git a/app/Http/Controllers/FeedbackPageController.php b/app/Http/Controllers/FeedbackPageController.php index afd3a30..b6069f3 100644 --- a/app/Http/Controllers/FeedbackPageController.php +++ b/app/Http/Controllers/FeedbackPageController.php @@ -12,8 +12,10 @@ use Illuminate\Support\Str; /** - * Read-only page of one feedback entry behind a signed link (feedback:link). - * The only HTML the API serves: no scripts, no external resources. + * Page of one feedback entry behind a signed link (feedback:link, share link): + * screenshot with the sender's message, technical details in a second tab. + * The only HTML the API serves: one nonce'd inline script (tabs, copy + * buttons), nothing loaded from other hosts. */ final class FeedbackPageController { @@ -24,6 +26,10 @@ public function __invoke(Request $request, FeedbackEntry $entry): Response // Metadata only, the BLOB is served by the screenshot link. $shot = $entry->screenshot()->select(['entry_id', 'mime', 'width', 'height', 'bytes'])->first(); $nonce = Str::random(32); + $environment = self::environment($entry); + $console = self::rows($entry->console_log, ['type', 'message', 'count', 'source', 'line', 'column', 'stack']); + $network = self::rows($entry->network_log, ['method', 'url', 'status', 'duration', 'count']); + $customData = $entry->custom_data === null || $entry->custom_data === [] ? null : (string) json_encode($entry->custom_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); return response()->view('feedback.show', [ 'entry' => $entry, @@ -33,20 +39,113 @@ public function __invoke(Request $request, FeedbackEntry $entry): Response 'downloadUrl' => $shot === null ? null : FeedbackLink::screenshot($entry, $expires, download: true), 'pageHref' => FeedbackLink::safeHref($entry->page_url), 'sentFromHref' => FeedbackLink::safeHref($entry->submitted_from_url), - 'console' => self::rows($entry->console_log, ['type', 'message', 'count']), - 'network' => self::rows($entry->network_log, ['method', 'url', 'status', 'duration', 'count']), - 'customData' => $entry->custom_data === null || $entry->custom_data === [] ? null : (string) json_encode($entry->custom_data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + 'environment' => $environment, + 'console' => $console, + 'consoleErrors' => count(array_filter($console, fn (array $row): bool => $row['type'] === 'error')), + 'network' => $network, + 'customData' => $customData, + 'report' => self::report($entry, $environment, $console, $network, $customData), 'statusUrl' => $request->boolean('manage') ? FeedbackLink::status($entry, $expires, is_string($by) ? $by : null) : null, 'expires' => $expires, 'nonce' => $nonce, ])->withHeaders([ - 'Content-Security-Policy' => "default-src 'none'; img-src 'self'; style-src 'nonce-{$nonce}'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + 'Content-Security-Policy' => "default-src 'none'; img-src 'self'; style-src 'nonce-{$nonce}'; script-src 'nonce-{$nonce}'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", 'Cache-Control' => 'private, no-store', 'Referrer-Policy' => 'no-referrer', 'X-Content-Type-Options' => 'nosniff', ]); } + /** @return array label => value, only what is known */ + private static function environment(FeedbackEntry $entry): array + { + $size = fn (?int $w, ?int $h): ?string => $w === null || $h === null ? null : "{$w} × {$h}"; + $screen = $size($entry->screen_w, $entry->screen_h); + + return array_filter([ + 'Browser' => trim(($entry->browser_name ?? '').' '.($entry->browser_version ?? '')) ?: null, + 'Operating system' => trim(($entry->os_name ?? '').' '.($entry->os_version ?? '')) ?: null, + 'Device' => $entry->device_type, + 'Viewport' => $size($entry->viewport_w, $entry->viewport_h), + 'Screen' => $screen === null ? null : $screen.($entry->dpr !== null ? " @ {$entry->dpr}x" : ''), + 'Page size' => $size($entry->page_w, $entry->page_h), + 'Scroll position' => $size($entry->scroll_x, $entry->scroll_y), + 'Color depth' => $entry->color_depth === null ? null : "{$entry->color_depth} bit", + 'Timezone' => $entry->timezone, + 'Browser language' => $entry->browser_language, + 'Widget language' => $entry->locale, + 'Page' => $entry->page_url, + 'Page title' => $entry->page_title, + 'Sent from' => $entry->submitted_from_url !== $entry->page_url ? $entry->submitted_from_url : null, + 'Referrer' => $entry->referrer, + 'Origin' => $entry->origin, + 'IP address' => $entry->ip, + 'User ID' => $entry->identity_user_id, + 'Time to send' => $entry->elapsed_ms === null ? null : number_format($entry->elapsed_ms / 1000, 1).' s', + 'Widget version' => $entry->widget_version, + 'User agent' => $entry->user_agent, + ], fn (?string $value): bool => $value !== null && $value !== ''); + } + + /** + * Plain text of the technical tab, for "Copy report" (tickets, chats). + * + * @param array $environment + * @param list> $console + * @param list> $network + */ + private static function report(FeedbackEntry $entry, array $environment, array $console, array $network, ?string $customData): string + { + $lines = ["Feedback {$entry->id} ({$entry->project->name}), {$entry->created_at->toDateTimeString()} UTC", '', $entry->message, '']; + + foreach ($environment as $label => $value) { + $lines[] = "{$label}: {$value}"; + } + + if ($console !== []) { + $lines[] = ''; + $lines[] = 'Console:'; + + foreach ($console as $row) { + $lines[] = self::consoleLine($row); + } + } + + if ($network !== []) { + $lines[] = ''; + $lines[] = 'Network:'; + + foreach ($network as $row) { + $lines[] = self::networkLine($row); + } + } + + if ($customData !== null) { + $lines[] = ''; + $lines[] = 'Custom data:'; + $lines[] = $customData; + } + + return implode("\n", $lines); + } + + /** @param array $row */ + public static function consoleLine(array $row): string + { + $where = $row['source'] === '' ? '' : ' ('.$row['source'].($row['line'] === '' ? '' : ':'.$row['line']).($row['column'] === '' ? '' : ':'.$row['column']).')'; + $times = (int) $row['count'] > 1 ? " ×{$row['count']}" : ''; + + return "[{$row['type']}] {$row['message']}{$where}{$times}".($row['stack'] === '' ? '' : "\n{$row['stack']}"); + } + + /** @param array $row */ + public static function networkLine(array $row): string + { + $times = (int) $row['count'] > 1 ? " ×{$row['count']}" : ''; + + return trim("{$row['method']} {$row['url']} → {$row['status']}".($row['duration'] === '' ? '' : " ({$row['duration']} ms)").$times); + } + /** * Logs come from the browser: every cell becomes a plain string. * diff --git a/e2e/feedback.e2e.ts b/e2e/feedback.e2e.ts index 29379c7..72768ef 100644 --- a/e2e/feedback.e2e.ts +++ b/e2e/feedback.e2e.ts @@ -195,6 +195,35 @@ test('the share link on the thank you screen and the team link open the feedback const img = viewer.locator('.shot img'); await expect(img).toBeVisible(); expect(await img.evaluate((el: HTMLImageElement) => el.complete && el.naturalWidth > 500)).toBe(true); + // Feedback first: the sender's message floats over the screenshot and can be hidden. + await expect(viewer.locator('#card')).toContainText('e2e@beispiel.ch'); + await viewer.getByRole('button', { name: 'Hide message' }).click(); + await expect(viewer.locator('.message')).toBeHidden(); + await viewer.getByRole('button', { name: 'Show message' }).click(); + await expect(viewer.locator('.message')).toBeVisible(); + // Technical details on the second tab, with copy buttons. + await expect(viewer.locator('#panel-technical')).toBeHidden(); + await viewer.getByRole('tab', { name: /Technical/ }).click(); + await expect(viewer.locator('#panel-technical')).toBeVisible(); + await expect(viewer.locator('#panel-feedback')).toBeHidden(); + expect(new URL(viewer.url()).hash).toBe('#technical'); + await expect(viewer.locator('#h-console')).toContainText('Console'); + await expect(viewer.locator('.kv')).toContainText('Viewport'); + const copyReport = viewer.getByRole('button', { name: 'Copy report' }); + if (browserName === 'chromium') { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await copyReport.click(); + const report = await viewer.evaluate(() => navigator.clipboard.readText()); + expect(report).toContain(message); + expect(report).toContain('Console:'); + } else { + await copyReport.click(); + } + await expect(copyReport).toHaveClass(/done/); + await viewer.reload(); + await expect(viewer.locator('#panel-technical')).toBeVisible(); + await viewer.getByRole('tab', { name: 'Feedback' }).click(); + const [download] = await Promise.all([viewer.waitForEvent('download'), viewer.getByText(/^Download/).click()]); expect(download.suggestedFilename()).toMatch(/^feedback-[0-9a-z]{26}\.jpg$/); diff --git a/resources/views/feedback/show.blade.php b/resources/views/feedback/show.blade.php index a3582e9..e8ebb5a 100644 --- a/resources/views/feedback/show.blade.php +++ b/resources/views/feedback/show.blade.php @@ -1,3 +1,10 @@ +@php + $sender = $entry->name !== null && $entry->name !== '' ? $entry->name : $entry->email; + $initials = collect(preg_split('/[\s@._-]+/', (string) $sender) ?: [])->filter()->take(2)->map(fn (string $part): string => mb_strtoupper(mb_substr($part, 0, 1)))->implode(''); + $marks = count($entry->annotations ?? []); + $short = trim(($entry->browser_name ?? '').' '.($entry->browser_version ?? '')).($entry->os_name !== null ? ' · '.trim($entry->os_name.' '.($entry->os_version ?? '')) : ''); + $pageLabel = preg_replace('#^https?://#i', '', $entry->page_url); +@endphp @@ -5,174 +12,382 @@ - Feedback {{ $entry->id }} · {{ $project->name }} + Feedback · {{ $project->name }} -
-
-

{{ $project->name }}

- {{ $entry->id }} - @if ($entry->completed_at === null) - open - @else - completed {{ $entry->completed_at->toDateTimeString() }} - @endif - @if ($statusUrl !== null) -
- @if ($entry->completed_at === null) - - @else - - @endif -
- @endif -
- -
-

Feedback

-

{{ $entry->message }}

-
+ -
-

Details

-
-
From
-
{{ trim(($entry->name ?? '').' <'.$entry->email.'>') }}
-
Created
-
{{ $entry->created_at->toDateTimeString() }} UTC
-
Page
-
- @if ($pageHref !== null) - {{ $entry->page_url }} - @else - {{ $entry->page_url }} - @endif -
- @if ($entry->page_title !== null) -
Page title
-
{{ $entry->page_title }}
+
+
+
+
+

{{ $project->name }}

+ {{ $entry->id }} + + +
+ @if ($entry->completed_at === null) + open + @else + completed + @endif +
+
+ @if ($shot !== null) + Download + Full size @endif - @if ($entry->submitted_from_url !== null && $entry->submitted_from_url !== $entry->page_url) -
Sent from
-
- @if ($sentFromHref !== null) - {{ $entry->submitted_from_url }} + @if ($statusUrl !== null) +
+ @if ($entry->completed_at === null) + @else - {{ $entry->submitted_from_url }} + @endif -
+ @endif -
Locale
-
{{ $entry->locale }}
-
System
-
{{ trim(($entry->os_name ?? '?').' '.($entry->os_version ?? '')) }}
-
Browser
-
{{ trim(($entry->browser_name ?? '?').' '.($entry->browser_version ?? '')) }} ({{ $entry->version_precision }})
- @if ($entry->device_type !== null) -
Device
-
{{ $entry->device_type }}
+
+
+
+ + +
+
+ +
+
+
+ @if ($shot !== null) + + Screenshot of {{ $entry->page_url }} + + @else +
+ +

{{ $entry->screenshot_error === null ? 'No screenshot.' : 'No screenshot ('.$entry->screenshot_error.').' }}

+
@endif -
+ + +
-
-

Screenshot

- @if ($shot !== null) -
- Download ({{ $shot->width }} × {{ $shot->height }}, {{ number_format($shot->bytes / 1024, 0, '.', "'") }} KB) - Open full size +
+
+

Everything the browser sent along. Copy single values or the whole report for a ticket.

+ +
+
+
+
+
+

Console {{ count($console) }}

+ @if ($console !== []) + + @endif +
+ @if ($console === []) +

No console entries.

+ @else +
    + @foreach ($console as $row) +
  • + {{ $row['type'] !== '' ? $row['type'] : 'log' }} +
    {{ $row['message'] }} + @if ($row['source'] !== '' || (int) $row['count'] > 1) + + @endif + @if ($row['stack'] !== '') +
    Stack trace
    {{ $row['stack'] }}
    + @endif +
    + +
  • + @endforeach +
+ @endif +
+ +
+
+

Failed requests {{ count($network) }}

+ @if ($network !== []) + + @endif +
+ @if ($network === []) +

No failed requests.

+ @else +
    + @foreach ($network as $row) +
  • + {{ $row['status'] !== '' && $row['status'] !== '0' ? $row['status'] : 'ERR' }} +
    {{ $row['method'] }} {{ $row['url'] }} + +
    + +
  • + @endforeach +
+ @endif +
+ + @if ($customData !== null) +
+
+

Custom data

+ +
+
{{ $customData }}
+
+ @endif
- Screenshot of {{ $entry->page_url }} - @else -

{{ $entry->screenshot_error === null ? 'No screenshot.' : 'No screenshot ('.$entry->screenshot_error.').' }}

- @endif -
+
+

Environment

+
+ @foreach ($environment as $label => $value) +
+
{{ $label }}
+
in_array($label, ['User agent', 'IP address'], true)])>{{ $value }}
+ +
+ @endforeach +
+
- @if ($console !== []) -
-

Console ({{ count($console) }})

- - - - @foreach ($console as $row) - - @endforeach - -
TypeMessageCount
{{ $row['type'] }}{{ $row['message'] }}{{ $row['count'] }}
-
- @endif - - @if ($network !== []) -
-

Network ({{ count($network) }})

- - - - @foreach ($network as $row) - - @endforeach - -
MethodURLStatusmsCount
{{ $row['method'] }}{{ $row['url'] }}{{ $row['status'] }}{{ $row['duration'] }}{{ $row['count'] }}
-
- @endif - - @if ($customData !== null) -
-

Custom data

-
{{ $customData }}
-
- @endif - -
This link expires {{ $expires->toDateTimeString() }} UTC.
+
+
+ +
This link expires {{ $expires->toDateTimeString() }} UTC. Anyone with the link can open this page until then.
+
+ + diff --git a/tests/Feature/FeedbackLinkTest.php b/tests/Feature/FeedbackLinkTest.php index 4020b49..b8c1912 100644 --- a/tests/Feature/FeedbackLinkTest.php +++ b/tests/Feature/FeedbackLinkTest.php @@ -86,11 +86,18 @@ function entryWithShot(FeedbackProject $project, array $attributes = []): Feedba ->assertSee('Alpha') ->assertSee('open') ->assertSee('Die Schaltfläche «Speichern» reagiert nicht.') - ->assertSee('Anna Muster <anna@beispiel.ch>', false) + ->assertSee('Anna Muster') + ->assertSee('anna@beispiel.ch', false) + ->assertSee('data-copy="anna@beispiel.ch"', false) ->assertSee('assertSee('Sent from') ->assertSee('https://www.beispiel.ch/zwei') - ->assertSee('
Browser
', false) + ->assertSee('
Viewport
', false) + ->assertSee('role="tab" id="tab-technical"', false) + ->assertSee('1 error') + ->assertSee('Copy report') + ->assertSee('data-copy="[error] Boom ×2"', false) + ->assertSee('POST https://api.beispiel.ch/save → 500 (120 ms)') ->assertSee('1541 × 947') ->assertSee('Europe/Zurich') ->assertSee('Boom') @@ -103,7 +110,18 @@ function entryWithShot(FeedbackProject $project, array $attributes = []): Feedba $csp = (string) $response->headers->get('Content-Security-Policy'); preg_match("/'nonce-([^']+)'/", $csp, $nonce); expect($csp)->toContain("default-src 'none'")->toContain("img-src 'self'")->toContain("frame-ancestors 'none'") - ->and($response->getContent())->toContain('