feat(quick-260914-m97): Fehler-melden-Knopf in der Kopfzeile — Bildschirmfoto vor dem Dialog (html-to-image 1.11.13), Fehlerpuffer, Dialog mit Vorschau, i18n bugReport
- Knopf (Kaefer-Symbol) unmittelbar vor dem Erscheinungsbild-Schalter; captureScreenshot() laeuft VOR dem Oeffnen, Test pinnt es im toPng-Mock - computeCaptureSize (laengste Kante 1600 px, reine Funktion, getestet), dataUrlToBlob, sendBugReport als Multipart mit credentials und ohne Content-Type-Header - error-buffer: Ringpuffer 20, window error/unhandledrejection, console.error (Original bleibt), fetch-Wrapper nur bei !ok ohne Anfrage-Rumpf/Suchteil/Kopfzeilen (T-M97-02), idempotent, SSR-sicher; installiert in app-shell - Dialog mit Vorschau, Haekchen, Beschreibung, Meldungen je Status 409/413/429/502/allgemein, Admin-Link auf /admin/smtp - i18n bugReport de/en, Allowlist um "passiert"; html-to-image exakt 1.11.13, Lockfile aktualisiert Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018N9CD3ebPKm1b32bPpBknY
This commit is contained in:
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@uiw/react-md-editor": "4.1.1",
|
||||
"fflate": "^0.8.3",
|
||||
"html-to-image": "1.11.13",
|
||||
"jose": "^6.2.3",
|
||||
"next": "^15.3.0",
|
||||
"next-intl": "^4.13.0",
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import de from '@/messages/de.json';
|
||||
import { clearErrorBuffer, recordError } from '@/lib/error-buffer';
|
||||
import { computeCaptureSize } from '@/lib/bug-report-api';
|
||||
|
||||
/**
|
||||
* bug-report-button.test — NEU (quick-260914-m97, Fehler-melden-Knopf).
|
||||
*
|
||||
* Elf Tests. Die Kernanforderung (Test 1): das Bild wird VOR dem Dialog
|
||||
* aufgenommen — der `toPng`-Mock prueft waehrend seines Aufrufs, dass noch
|
||||
* KEIN `role="dialog"` im DOM steht. `html-to-image` selbst laeuft in jsdom
|
||||
* nicht (gemessen zur Planungszeit: `HTMLVideoElement is not defined`, kein
|
||||
* Canvas-Backend) — deshalb der Mock; der Bildbeweis kommt aus dem Browser.
|
||||
*
|
||||
* Der next-intl-Mock liest die Texte aus der ECHTEN `de.json` (Muster
|
||||
* `tessera-logo.test.tsx`), Erwartungen zitieren `de.bugReport.<key>`.
|
||||
*/
|
||||
const { mockToPng, mockFetch, mockUser } = vi.hoisted(() => ({
|
||||
mockToPng: vi.fn(),
|
||||
mockFetch: vi.fn(),
|
||||
mockUser: { id: 'u1', username: 'anna', displayName: 'Anna', role: 'USER', tenantId: 't1' } as {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string | null;
|
||||
role: string;
|
||||
tenantId: string;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('html-to-image', () => ({
|
||||
toPng: (...args: unknown[]) => mockToPng(...args),
|
||||
}));
|
||||
|
||||
vi.mock('next-intl', async () => {
|
||||
const messages = (await import('@/messages/de.json')).default as Record<string, unknown>;
|
||||
const lookup = (path: string): string | undefined =>
|
||||
path.split('.').reduce<unknown>((o, k) => (o && typeof o === 'object' ? (o as any)[k] : undefined), messages) as
|
||||
| string
|
||||
| undefined;
|
||||
return {
|
||||
useTranslations: (ns?: string) => (key: string) => lookup(ns ? `${ns}.${key}` : key) ?? key,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('next/link', () => ({
|
||||
default: ({ children, href, className }: { children: React.ReactNode; href: string; className?: string }) => (
|
||||
<a href={href} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/stores/auth-store', () => ({
|
||||
useAuthStore: (sel?: (s: { user: typeof mockUser }) => unknown) =>
|
||||
sel ? sel({ user: mockUser }) : { user: mockUser },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/app-version', () => ({
|
||||
appVersion: { version: 'v1.2.3', channel: 'beta', commit: 'abc1234' },
|
||||
}));
|
||||
|
||||
const T = de.bugReport;
|
||||
const DATA_URL = 'data:image/png;base64,iVBORw0KGgo=';
|
||||
|
||||
async function renderButton() {
|
||||
const { BugReportButton } = await import('./bug-report-button');
|
||||
render(<BugReportButton />);
|
||||
}
|
||||
|
||||
async function openDialog(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: T.button }));
|
||||
return screen.findByRole('dialog');
|
||||
}
|
||||
|
||||
function jsonResponse(status: number, body: unknown = { statusCode: status }) {
|
||||
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUser.role = 'USER';
|
||||
mockToPng.mockReset();
|
||||
mockToPng.mockResolvedValue(DATA_URL);
|
||||
mockFetch.mockReset();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
clearErrorBuffer();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('BugReportButton (quick-260914-m97)', () => {
|
||||
it('Test 1: Bild VOR dem Dialog — toPng laeuft ohne offenen Dialog, mit document.body, pixelRatio 1, skipFonts, 1600-px-Kante; danach Dialog mit Vorschau, Haekchen an, Textfeld leer', async () => {
|
||||
Object.defineProperty(document.body, 'scrollWidth', { value: 3200, configurable: true });
|
||||
Object.defineProperty(document.body, 'scrollHeight', { value: 1000, configurable: true });
|
||||
mockToPng.mockImplementation(async () => {
|
||||
expect(screen.queryByRole('dialog')).toBeNull();
|
||||
return DATA_URL;
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
await renderButton();
|
||||
|
||||
const dialog = await openDialog(user);
|
||||
|
||||
expect(mockToPng).toHaveBeenCalledTimes(1);
|
||||
const [node, opts] = mockToPng.mock.calls[0] as [unknown, Record<string, unknown>];
|
||||
expect(node).toBe(document.body);
|
||||
expect(opts).toMatchObject({ pixelRatio: 1, skipFonts: true, canvasWidth: 1600, canvasHeight: 500 });
|
||||
expect(dialog).toBeInTheDocument();
|
||||
const img = screen.getByAltText(T.screenshotAlt) as HTMLImageElement;
|
||||
expect(img.getAttribute('src')).toBe(DATA_URL);
|
||||
expect(screen.getByRole('checkbox', { name: T.attachScreenshot })).toBeChecked();
|
||||
expect(screen.getByLabelText(T.descriptionLabel)).toHaveValue('');
|
||||
});
|
||||
|
||||
it('Test 2: Senden mit Bild — multipart POST /bug-reports mit credentials, allen Feldern, Fehlerliste, PNG-Blob, ohne Content-Type-Header; danach Dankestext und Schliessen-Knopf', async () => {
|
||||
recordError('fetch', 'GET /modules -> 500');
|
||||
mockFetch.mockResolvedValue(jsonResponse(200, { sent: true }));
|
||||
const user = userEvent.setup();
|
||||
await renderButton();
|
||||
await openDialog(user);
|
||||
|
||||
await user.type(screen.getByLabelText(T.descriptionLabel), 'Knopf tut nichts');
|
||||
await user.click(screen.getByRole('button', { name: T.send }));
|
||||
|
||||
await screen.findByText(T.sent);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(String(url).endsWith('/bug-reports')).toBe(true);
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.credentials).toBe('include');
|
||||
expect(init.body).toBeInstanceOf(FormData);
|
||||
const body = init.body as FormData;
|
||||
expect(body.get('description')).toBe('Knopf tut nichts');
|
||||
expect(body.get('webVersion')).toBe('v1.2.3');
|
||||
expect(body.get('webChannel')).toBe('beta');
|
||||
expect(body.get('webCommit')).toBe('abc1234');
|
||||
expect(String(body.get('page')).startsWith('/')).toBe(true);
|
||||
expect(body.get('userAgent')).toBeTruthy();
|
||||
expect(body.get('viewport')).toMatch(/^\d+x\d+$/);
|
||||
expect(body.get('clientTime')).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
expect(body.getAll('errors').some((e) => String(e).includes('GET /modules -> 500'))).toBe(true);
|
||||
const shot = body.get('screenshot');
|
||||
expect(shot).toBeInstanceOf(Blob);
|
||||
expect((shot as Blob).type).toBe('image/png');
|
||||
expect((shot as Blob).size).toBeGreaterThan(0);
|
||||
const headers = (init.headers ?? {}) as Record<string, string>;
|
||||
expect(Object.keys(headers).map((k) => k.toLowerCase())).not.toContain('content-type');
|
||||
expect(screen.getByRole('button', { name: T.close })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Test 3: Haekchen aus -> kein Feld screenshot im Rumpf', async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse(200, { sent: true }));
|
||||
const user = userEvent.setup();
|
||||
await renderButton();
|
||||
await openDialog(user);
|
||||
|
||||
await user.click(screen.getByRole('checkbox', { name: T.attachScreenshot }));
|
||||
await user.click(screen.getByRole('button', { name: T.send }));
|
||||
|
||||
await screen.findByText(T.sent);
|
||||
const body = (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as FormData;
|
||||
expect(body.has('screenshot')).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 4: 409 — Meldung "kein Postfach"; als ADMIN zusaetzlich Hinweis mit Link auf /admin/smtp, als USER kein Link', async () => {
|
||||
mockUser.role = 'ADMIN';
|
||||
mockFetch.mockResolvedValue(jsonResponse(409, { statusCode: 409, message: 'kein Postfach' }));
|
||||
const user = userEvent.setup();
|
||||
await renderButton();
|
||||
await openDialog(user);
|
||||
await user.click(screen.getByRole('button', { name: T.send }));
|
||||
|
||||
await screen.findByText(T.errorNotConfigured);
|
||||
expect(screen.getByText(T.errorNotConfiguredAdminHint)).toBeInTheDocument();
|
||||
const link = screen.getByRole('link', { name: T.errorNotConfiguredAdminLink });
|
||||
expect(link.getAttribute('href')).toBe('/admin/smtp');
|
||||
|
||||
cleanup();
|
||||
mockUser.role = 'USER';
|
||||
await renderButton();
|
||||
await openDialog(user);
|
||||
await user.click(screen.getByRole('button', { name: T.send }));
|
||||
|
||||
await screen.findByText(T.errorNotConfigured);
|
||||
expect(screen.queryByRole('link', { name: T.errorNotConfiguredAdminLink })).toBeNull();
|
||||
expect(screen.queryByText(T.errorNotConfiguredAdminHint)).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 5: Escape schliesst den Dialog', async () => {
|
||||
const user = userEvent.setup();
|
||||
await renderButton();
|
||||
await openDialog(user);
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
|
||||
});
|
||||
|
||||
it('Test 6: Aufnahme scheitert -> Dialog oeffnet trotzdem mit Hinweis, Haekchen abgeschaltet und aus, Senden ohne screenshot', async () => {
|
||||
mockToPng.mockRejectedValue(new Error('kein Canvas'));
|
||||
mockFetch.mockResolvedValue(jsonResponse(200, { sent: true }));
|
||||
const user = userEvent.setup();
|
||||
await renderButton();
|
||||
await openDialog(user);
|
||||
|
||||
expect(screen.getByText(T.screenshotUnavailable)).toBeInTheDocument();
|
||||
const box = screen.getByRole('checkbox', { name: T.attachScreenshot });
|
||||
expect(box).toBeDisabled();
|
||||
expect(box).not.toBeChecked();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: T.send }));
|
||||
await screen.findByText(T.sent);
|
||||
const body = (mockFetch.mock.calls[0] as [string, RequestInit])[1].body as FormData;
|
||||
expect(body.has('screenshot')).toBe(false);
|
||||
});
|
||||
|
||||
it('Test 7: 413 -> errorTooLarge; Knoepfe bleiben, erneutes Senden ruft fetch ein zweites Mal', async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse(413));
|
||||
const user = userEvent.setup();
|
||||
await renderButton();
|
||||
await openDialog(user);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: T.send }));
|
||||
await screen.findByText(T.errorTooLarge);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: T.send }));
|
||||
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
|
||||
it('Test 8: 429 -> errorTooMany, nicht errorTooLarge', async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse(429));
|
||||
const user = userEvent.setup();
|
||||
await renderButton();
|
||||
await openDialog(user);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: T.send }));
|
||||
|
||||
await screen.findByText(T.errorTooMany);
|
||||
expect(screen.queryByText(T.errorTooLarge)).toBeNull();
|
||||
});
|
||||
|
||||
it('Test 9: 502 -> errorSendFailed', async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse(502));
|
||||
const user = userEvent.setup();
|
||||
await renderButton();
|
||||
await openDialog(user);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: T.send }));
|
||||
|
||||
await screen.findByText(T.errorSendFailed);
|
||||
});
|
||||
|
||||
it('Test 10: 500 und Netzwerkfehler -> errorGeneric, keine der vier spezifischen Meldungen', async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse(500));
|
||||
const user = userEvent.setup();
|
||||
await renderButton();
|
||||
await openDialog(user);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: T.send }));
|
||||
await screen.findByText(T.errorGeneric);
|
||||
|
||||
mockFetch.mockRejectedValueOnce(new Error('netz'));
|
||||
await user.click(screen.getByRole('button', { name: T.send }));
|
||||
await waitFor(() => expect(mockFetch).toHaveBeenCalledTimes(2));
|
||||
await screen.findByText(T.errorGeneric);
|
||||
for (const specific of [T.errorNotConfigured, T.errorTooMany, T.errorTooLarge, T.errorSendFailed]) {
|
||||
expect(screen.queryByText(specific)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeCaptureSize (quick-260914-m97, reine Funktion)', () => {
|
||||
it('Test 11: laengste Kante hoechstens 1600 px, Seitenverhaeltnis bleibt, Mindestmass 1x1, maxEdge einstellbar', () => {
|
||||
expect(computeCaptureSize(3200, 1000)).toEqual({ width: 1600, height: 500 });
|
||||
expect(computeCaptureSize(800, 600)).toEqual({ width: 800, height: 600 });
|
||||
expect(computeCaptureSize(1000, 4000)).toEqual({ width: 400, height: 1600 });
|
||||
expect(computeCaptureSize(0, 0)).toEqual({ width: 1, height: 1 });
|
||||
expect(computeCaptureSize(3200, 1000, 800)).toEqual({ width: 800, height: 250 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { captureScreenshot } from '@/lib/bug-report-api';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { BugReportDialog } from './bug-report-dialog';
|
||||
|
||||
/**
|
||||
* Fehler-melden-Knopf in der Kopfzeile (quick-260914-m97), Stil wie
|
||||
* `ThemeToggle`. Kernanforderung: das Bild der Seite wird aufgenommen,
|
||||
* BEVOR der Dialog erscheint — sonst waere der Dialog im Bild.
|
||||
*/
|
||||
export function BugReportButton() {
|
||||
const t = useTranslations('bugReport');
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN';
|
||||
const [capturing, setCapturing] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [screenshot, setScreenshot] = useState<string | null>(null);
|
||||
|
||||
const handleClick = async () => {
|
||||
if (capturing) return;
|
||||
setCapturing(true);
|
||||
// Reihenfolge ist die Kernanforderung: erst aufnehmen, dann oeffnen.
|
||||
const shot = await captureScreenshot();
|
||||
setScreenshot(shot);
|
||||
setOpen(true);
|
||||
setCapturing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors disabled:opacity-50"
|
||||
aria-label={t('button')}
|
||||
title={t('button')}
|
||||
disabled={capturing}
|
||||
data-bug-report-ignore="true"
|
||||
>
|
||||
{/* Kaefer-Symbol nach dem lucide-Symbol "bug" */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="m8 2 1.88 1.88" />
|
||||
<path d="M14.12 3.88 16 2" />
|
||||
<path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1" />
|
||||
<path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6" />
|
||||
<path d="M12 20v-9" />
|
||||
<path d="M6.53 9C4.6 8.8 3 7.1 3 5" />
|
||||
<path d="M6 13H2" />
|
||||
<path d="M3 21c0-2.1 1.7-3.9 3.8-4" />
|
||||
<path d="M20.97 5c0 2.1-1.6 3.8-3.5 4" />
|
||||
<path d="M22 13h-4" />
|
||||
<path d="M17.2 17c2.1.1 3.8 1.9 3.8 4" />
|
||||
</svg>
|
||||
</button>
|
||||
<BugReportDialog open={open} screenshot={screenshot} isAdmin={isAdmin} onClose={() => setOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { appVersion } from '@/lib/app-version';
|
||||
import { dataUrlToBlob, sendBugReport } from '@/lib/bug-report-api';
|
||||
import { formatErrorsForReport } from '@/lib/error-buffer';
|
||||
|
||||
/**
|
||||
* Dialog des Fehler-melden-Knopfs (quick-260914-m97). Muster:
|
||||
* `marketplace/components/ActivationDialog.tsx` (Overlay, `role="dialog"`,
|
||||
* Escape, Fokus). Bekommt das bereits aufgenommene Bild als Data-URL —
|
||||
* die Aufnahme passiert im Knopf, BEVOR dieser Dialog erscheint.
|
||||
*/
|
||||
interface BugReportDialogProps {
|
||||
open: boolean;
|
||||
screenshot: string | null;
|
||||
isAdmin: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Status = 'ready' | 'sending' | 'sent' | 'failed';
|
||||
|
||||
export function BugReportDialog({ open, screenshot, isAdmin, onClose }: BugReportDialogProps) {
|
||||
const t = useTranslations('bugReport');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [status, setStatus] = useState<Status>('ready');
|
||||
const [failedStatus, setFailedStatus] = useState(0);
|
||||
const [description, setDescription] = useState('');
|
||||
const [attach, setAttach] = useState(screenshot !== null);
|
||||
|
||||
// Bei jedem Oeffnen frisch beginnen.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStatus('ready');
|
||||
setFailedStatus(0);
|
||||
setDescription('');
|
||||
setAttach(screenshot !== null);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [open, screenshot]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && status !== 'sending') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [open, status, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSend = async () => {
|
||||
setStatus('sending');
|
||||
const result = await sendBugReport({
|
||||
description: description.trim(),
|
||||
page: window.location.pathname + window.location.search,
|
||||
webVersion: appVersion.version,
|
||||
webChannel: appVersion.channel,
|
||||
webCommit: appVersion.commit,
|
||||
userAgent: navigator.userAgent,
|
||||
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
||||
clientTime: new Date().toISOString(),
|
||||
errors: formatErrorsForReport(),
|
||||
screenshot: attach && screenshot ? dataUrlToBlob(screenshot) : null,
|
||||
});
|
||||
if (result.ok) {
|
||||
setStatus('sent');
|
||||
} else {
|
||||
setFailedStatus(result.status);
|
||||
setStatus('failed');
|
||||
}
|
||||
};
|
||||
|
||||
const errorKey =
|
||||
failedStatus === 409
|
||||
? 'errorNotConfigured'
|
||||
: failedStatus === 429
|
||||
? 'errorTooMany'
|
||||
: failedStatus === 413
|
||||
? 'errorTooLarge'
|
||||
: failedStatus === 502
|
||||
? 'errorSendFailed'
|
||||
: 'errorGeneric';
|
||||
|
||||
const busy = status === 'sending';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="bug-report-title"
|
||||
data-bug-report-ignore="true"
|
||||
>
|
||||
<div className="w-full max-w-lg rounded-lg border border-border bg-card p-6 shadow-lg">
|
||||
<h2 id="bug-report-title" className="text-lg font-semibold text-foreground mb-2">
|
||||
{t('title')}
|
||||
</h2>
|
||||
|
||||
{status === 'sent' ? (
|
||||
<>
|
||||
<p className="text-sm text-foreground mb-4">{t('sent')}</p>
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
{t('close')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground mb-4">{t('intro')}</p>
|
||||
|
||||
{screenshot ? (
|
||||
<img
|
||||
src={screenshot}
|
||||
alt={t('screenshotAlt')}
|
||||
className="max-h-48 w-auto rounded border border-border mb-3"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mb-3">{t('screenshotUnavailable')}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<input
|
||||
id="bug-report-attach"
|
||||
type="checkbox"
|
||||
className="h-4 w-4"
|
||||
checked={attach}
|
||||
disabled={screenshot === null || busy}
|
||||
onChange={(e) => setAttach(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="bug-report-attach" className="text-sm text-foreground">
|
||||
{t('attachScreenshot')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label htmlFor="bug-report-description" className="mb-1 block text-sm text-foreground">
|
||||
{t('descriptionLabel')}
|
||||
</label>
|
||||
<textarea
|
||||
id="bug-report-description"
|
||||
ref={textareaRef}
|
||||
rows={4}
|
||||
maxLength={4000}
|
||||
placeholder={t('descriptionPlaceholder')}
|
||||
className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground"
|
||||
value={description}
|
||||
disabled={busy}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
|
||||
{status === 'failed' && (
|
||||
<div className="mt-3 text-sm text-destructive">
|
||||
<p>{t(errorKey)}</p>
|
||||
{failedStatus === 409 && isAdmin && (
|
||||
<p className="mt-1">
|
||||
{t('errorNotConfiguredAdminHint')}{' '}
|
||||
<Link href="/admin/smtp" className="underline">
|
||||
{t('errorNotConfiguredAdminLink')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={busy}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted disabled:opacity-50"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSend}
|
||||
disabled={busy}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
||||
>
|
||||
{busy ? t('sending') : t('send')}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||
import { installErrorBuffer } from '@/lib/error-buffer';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { Sidebar } from '@/components/layout/sidebar';
|
||||
|
||||
@@ -13,6 +14,12 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// Fehlerpuffer fuer den Fehler-melden-Knopf (quick-260914-m97): einmal je
|
||||
// Seitenladung, SSR-sicher, idempotent (StrictMode-Doppeleffekt unschaedlich).
|
||||
useEffect(() => {
|
||||
installErrorBuffer();
|
||||
}, []);
|
||||
|
||||
// Desktop: sidebar pushes content via margin-left
|
||||
// Mobile: sidebar overlays, no margin needed (handled by app-shell-main class in globals.css)
|
||||
const sidebarWidth = isCollapsed
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useSidebarStore } from '@/lib/stores/sidebar-store';
|
||||
import { useAuthStore } from '@/lib/stores/auth-store';
|
||||
import { fetchCurrentUser, logout } from '@/lib/auth-actions';
|
||||
import { ThemeToggle } from '@/components/theme-toggle';
|
||||
import { BugReportButton } from '@/components/bug-report/bug-report-button';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export function Header() {
|
||||
@@ -109,6 +110,7 @@ export function Header() {
|
||||
|
||||
{/* Right: Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
<BugReportButton />
|
||||
<ThemeToggle />
|
||||
|
||||
{/* User avatar dropdown */}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* bug-report-api — Bildaufnahme und Versand des Fehler-melden-Knopfs
|
||||
* (quick-260914-m97).
|
||||
*
|
||||
* `captureScreenshot` rastert `document.body` ueber `html-to-image`
|
||||
* (SVG `foreignObject`, der Browser zeichnet selbst — deshalb stimmen die
|
||||
* OKLCH-Farben von Tailwind 4, an denen `html2canvas` scheitert). Die
|
||||
* Bibliothek wird erst beim Klick dynamisch geladen. Die laengste Kante
|
||||
* ist auf 1600 px begrenzt (`computeCaptureSize`), `pixelRatio: 1`, keine
|
||||
* Schrift-Einbettung (`skipFonts` — das Projekt hat keinen Webfont).
|
||||
*
|
||||
* `sendBugReport` schickt `multipart/form-data` an `POST /bug-reports`
|
||||
* — das Bild als Datei, alle uebrigen Felder als Text — mit Cookie
|
||||
* (`credentials: 'include'`) und OHNE eigenen Content-Type-Header: die
|
||||
* Multipart-Grenze setzt der Browser selbst.
|
||||
*/
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||
|
||||
/**
|
||||
* Zielmass fuer die Aufnahme: laengste Kante hoechstens `maxEdge`,
|
||||
* Seitenverhaeltnis bleibt, nie kleiner als 1x1 (kein 0-Canvas).
|
||||
* Reine Funktion, direkt getestet.
|
||||
*/
|
||||
export function computeCaptureSize(
|
||||
width: number,
|
||||
height: number,
|
||||
maxEdge = 1600,
|
||||
): { width: number; height: number } {
|
||||
const w = Math.max(1, Math.round(width || 0));
|
||||
const h = Math.max(1, Math.round(height || 0));
|
||||
const scale = Math.min(1, maxEdge / Math.max(w, h));
|
||||
return {
|
||||
width: Math.max(1, Math.round(w * scale)),
|
||||
height: Math.max(1, Math.round(h * scale)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Nimmt die aktuelle Seite als PNG-Data-URL auf. Liefert `null` statt zu
|
||||
* werfen: das Bild ist eine Beigabe, der Bericht geht auch ohne.
|
||||
*/
|
||||
export async function captureScreenshot(): Promise<string | null> {
|
||||
try {
|
||||
const { toPng } = await import('html-to-image');
|
||||
const node = document.body;
|
||||
const size = computeCaptureSize(node.scrollWidth, node.scrollHeight);
|
||||
return await toPng(node, {
|
||||
pixelRatio: 1,
|
||||
skipFonts: true,
|
||||
cacheBust: true,
|
||||
canvasWidth: size.width,
|
||||
canvasHeight: size.height,
|
||||
filter: (n: Node) => !(n instanceof HTMLElement && n.dataset.bugReportIgnore === 'true'),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Base64-Teil einer Data-URL -> PNG-Blob (per `atob`, kein fetch). */
|
||||
export function dataUrlToBlob(dataUrl: string): Blob {
|
||||
const comma = dataUrl.indexOf(',');
|
||||
const base64 = comma >= 0 ? dataUrl.slice(comma + 1) : dataUrl;
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return new Blob([bytes], { type: 'image/png' });
|
||||
}
|
||||
|
||||
export interface BugReportPayload {
|
||||
description: string;
|
||||
page: string;
|
||||
webVersion: string;
|
||||
webChannel: string;
|
||||
webCommit: string;
|
||||
userAgent: string;
|
||||
viewport: string;
|
||||
clientTime: string;
|
||||
errors: string[];
|
||||
screenshot: Blob | null;
|
||||
}
|
||||
|
||||
export type BugReportResult = { ok: true } | { ok: false; status: number };
|
||||
|
||||
export async function sendBugReport(p: BugReportPayload): Promise<BugReportResult> {
|
||||
const body = new FormData();
|
||||
body.append('description', p.description);
|
||||
body.append('page', p.page);
|
||||
body.append('webVersion', p.webVersion);
|
||||
body.append('webChannel', p.webChannel);
|
||||
body.append('webCommit', p.webCommit);
|
||||
body.append('userAgent', p.userAgent);
|
||||
body.append('viewport', p.viewport);
|
||||
body.append('clientTime', p.clientTime);
|
||||
for (const line of p.errors) body.append('errors', line);
|
||||
if (p.screenshot) body.append('screenshot', p.screenshot, 'screenshot.png');
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_URL}/bug-reports`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body,
|
||||
});
|
||||
return res.ok ? { ok: true } : { ok: false, status: res.status };
|
||||
} catch {
|
||||
return { ok: false, status: 0 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
clearErrorBuffer,
|
||||
formatErrorsForReport,
|
||||
getRecentErrors,
|
||||
installErrorBuffer,
|
||||
recordError,
|
||||
uninstallErrorBuffer,
|
||||
} from './error-buffer';
|
||||
|
||||
/**
|
||||
* error-buffer.test — NEU (quick-260914-m97, Fehler-melden-Knopf).
|
||||
*
|
||||
* Vier Tests fuer den Browser-Fehlerpuffer, darunter die Sicherheitsregel
|
||||
* T-M97-02: der fetch-Wrapper notiert NUR fehlgeschlagene Antworten mit
|
||||
* Methode, Pfad OHNE Suchteil, Status und 200 Zeichen des ANTWORT-Rumpfs —
|
||||
* nie den Anfrage-Rumpf (Kennwoerter), nie Kopfzeilen, nie den Suchteil
|
||||
* (Tokens). Test 2 pinnt `geheim` und `password` als NICHT im Puffer.
|
||||
*/
|
||||
afterEach(() => {
|
||||
uninstallErrorBuffer();
|
||||
clearErrorBuffer();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('error-buffer (quick-260914-m97)', () => {
|
||||
it('Test 1: Ringpuffer haelt 20 Eintraege — 25 Meldungen -> die ersten fuenf fallen raus', () => {
|
||||
for (let i = 0; i < 25; i++) recordError('error', `m${i}`);
|
||||
const entries = getRecentErrors();
|
||||
expect(entries).toHaveLength(20);
|
||||
expect(entries[0].message).toBe('m5');
|
||||
expect(entries[19].message).toBe('m24');
|
||||
for (const e of entries) {
|
||||
expect(e.kind).toBe('error');
|
||||
expect(typeof e.message).toBe('string');
|
||||
expect(() => new Date(e.at).toISOString()).not.toThrow();
|
||||
expect(e.at).toBe(new Date(e.at).toISOString());
|
||||
}
|
||||
});
|
||||
|
||||
it('Test 2 (T-M97-02): fetch-Wrapper notiert nur !ok — Methode, Pfad ohne Suchteil, Status, Antwort-Auszug; nie Anfrage-Rumpf; Antwort bleibt lesbar; ok-Antworten werden nicht notiert', async () => {
|
||||
let status = 500;
|
||||
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
new Response('{"statusCode":500,"message":"kaputt"}', { status }),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
installErrorBuffer();
|
||||
|
||||
const res = await fetch('/api/x?token=geheim', { method: 'POST', body: '{"password":"p"}' });
|
||||
const entries = getRecentErrors();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].kind).toBe('fetch');
|
||||
expect(entries[0].message.startsWith('POST /api/x -> 500')).toBe(true);
|
||||
expect(entries[0].message).toContain('kaputt');
|
||||
expect(entries[0].message).not.toContain('geheim');
|
||||
expect(entries[0].message).not.toContain('password');
|
||||
expect(entries[0].message).not.toContain('token=');
|
||||
await expect(res.json()).resolves.toEqual({ statusCode: 500, message: 'kaputt' });
|
||||
|
||||
status = 200;
|
||||
await fetch('/api/y');
|
||||
expect(getRecentErrors()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('Test 3: console.error ruft weiterhin das Original und notiert die Argumente als Text', () => {
|
||||
const orig = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
installErrorBuffer();
|
||||
|
||||
console.error('boom', { a: 1 });
|
||||
|
||||
expect(orig).toHaveBeenCalledTimes(1);
|
||||
expect(orig).toHaveBeenCalledWith('boom', { a: 1 });
|
||||
const entries = getRecentErrors();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].kind).toBe('console.error');
|
||||
expect(entries[0].message).toContain('boom');
|
||||
});
|
||||
|
||||
it('Test 4: Installation ist idempotent (kein doppeltes Wrapping); formatErrorsForReport liefert "[<ISO>] fetch: ..."', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('nein', { status: 503 })));
|
||||
installErrorBuffer();
|
||||
installErrorBuffer();
|
||||
|
||||
await fetch('/api/z');
|
||||
const entries = getRecentErrors();
|
||||
expect(entries).toHaveLength(1);
|
||||
|
||||
const lines = formatErrorsForReport();
|
||||
expect(lines).toHaveLength(1);
|
||||
expect(lines[0]).toMatch(/^\[\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\] fetch: GET \/api\/z -> 503/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* error-buffer — Ringpuffer der letzten Fehlermeldungen im Browser
|
||||
* (quick-260914-m97, Fehler-melden-Knopf).
|
||||
*
|
||||
* Zweck: wenn ein Anwender „Fehler melden“ klickt, kommen die letzten 20
|
||||
* Fehler mit, die der Browser im Hintergrund gesehen hat — unbehandelte
|
||||
* Ausnahmen, abgelehnte Promises, `console.error`-Aufrufe und fehlgeschlagene
|
||||
* API-Antworten. Der Administrator sieht damit im Bericht, was VOR dem
|
||||
* Klick schiefging, ohne Rueckfragen stellen zu muessen.
|
||||
*
|
||||
* Grenzen: 20 Eintraege, je hoechstens 1000 Zeichen, Antwort-Auszug 200
|
||||
* Zeichen. Einmal je Seitenladung installiert (`installErrorBuffer`,
|
||||
* idempotent ueber ein Guard-Symbol auf `window`), SSR-sicher (ohne
|
||||
* `window` passiert nichts).
|
||||
*
|
||||
* Sicherheitsregel (T-M97-02): der fetch-Wrapper notiert NUR bei
|
||||
* `!response.ok` — Methode, Pfad OHNE Suchteil, Status und die ersten 200
|
||||
* Zeichen des ANTWORT-Rumpfs (aus einem `clone()`, die Antwort bleibt fuer
|
||||
* den Aufrufer lesbar). Nie der Anfrage-Rumpf (Kennwoerter, Formulare),
|
||||
* nie Kopfzeilen, nie Cookies (das Sitzungs-Cookie ist httpOnly und fuer
|
||||
* JavaScript ohnehin unsichtbar), nie der Suchteil (Tokens in URLs).
|
||||
*/
|
||||
|
||||
export type BufferedErrorKind = 'error' | 'unhandledrejection' | 'console.error' | 'fetch';
|
||||
|
||||
export interface BufferedError {
|
||||
at: string;
|
||||
kind: BufferedErrorKind;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 20;
|
||||
const MAX_MESSAGE = 1000;
|
||||
const BODY_EXCERPT = 200;
|
||||
const GUARD = '__tesseraErrorBufferInstalled';
|
||||
|
||||
const buffer: BufferedError[] = [];
|
||||
|
||||
let originalFetch: typeof fetch | null = null;
|
||||
let originalConsoleError: typeof console.error | null = null;
|
||||
let listeners: { error: (e: ErrorEvent) => void; rejection: (e: PromiseRejectionEvent) => void } | null = null;
|
||||
|
||||
export function recordError(kind: BufferedErrorKind, message: string): void {
|
||||
const text = String(message ?? '');
|
||||
buffer.push({
|
||||
at: new Date().toISOString(),
|
||||
kind,
|
||||
message: text.length > MAX_MESSAGE ? text.slice(0, MAX_MESSAGE) : text,
|
||||
});
|
||||
while (buffer.length > MAX_ENTRIES) buffer.shift();
|
||||
}
|
||||
|
||||
export function getRecentErrors(): BufferedError[] {
|
||||
return buffer.map((e) => ({ ...e }));
|
||||
}
|
||||
|
||||
export function clearErrorBuffer(): void {
|
||||
buffer.length = 0;
|
||||
}
|
||||
|
||||
/** Zeilen der Form `[<ISO>] <Art>: <Meldung>` fuer das Feld `errors` des Berichts. */
|
||||
export function formatErrorsForReport(): string[] {
|
||||
return buffer.map((e) => `[${e.at}] ${e.kind}: ${e.message}`);
|
||||
}
|
||||
|
||||
function argToText(arg: unknown): string {
|
||||
if (typeof arg === 'string') return arg;
|
||||
if (arg instanceof Error) return arg.message;
|
||||
try {
|
||||
return JSON.stringify(arg);
|
||||
} catch {
|
||||
return String(arg);
|
||||
}
|
||||
}
|
||||
|
||||
function pathOf(input: RequestInfo | URL): string {
|
||||
try {
|
||||
const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
return new URL(String(raw), window.location.href).pathname;
|
||||
} catch {
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
|
||||
export function installErrorBuffer(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
if (w[GUARD]) return;
|
||||
w[GUARD] = true;
|
||||
|
||||
const onError = (e: ErrorEvent) => {
|
||||
recordError('error', `${e.message} @ ${e.filename}:${e.lineno}`);
|
||||
};
|
||||
const onRejection = (e: PromiseRejectionEvent) => {
|
||||
const reason = e.reason as { message?: unknown } | undefined;
|
||||
recordError('unhandledrejection', String(reason?.message ?? e.reason));
|
||||
};
|
||||
window.addEventListener('error', onError);
|
||||
window.addEventListener('unhandledrejection', onRejection);
|
||||
listeners = { error: onError, rejection: onRejection };
|
||||
|
||||
// console.error: ZUERST das Original mit denselben Argumenten, dann notieren.
|
||||
const origConsoleError = console.error;
|
||||
originalConsoleError = origConsoleError;
|
||||
console.error = (...args: unknown[]) => {
|
||||
origConsoleError.apply(console, args);
|
||||
recordError('console.error', args.map(argToText).join(' '));
|
||||
};
|
||||
|
||||
// fetch: nur fehlgeschlagene Antworten, nie der Anfrage-Rumpf (T-M97-02).
|
||||
const origFetch = window.fetch;
|
||||
originalFetch = origFetch;
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const method = String(init?.method ?? 'GET').toUpperCase();
|
||||
let response: Response;
|
||||
try {
|
||||
response = await origFetch(input, init);
|
||||
} catch (err) {
|
||||
recordError('fetch', `${method} ${pathOf(input)} -> Netzwerkfehler`);
|
||||
throw err;
|
||||
}
|
||||
if (!response.ok) {
|
||||
let excerpt = '';
|
||||
try {
|
||||
excerpt = (await response.clone().text()).slice(0, BODY_EXCERPT);
|
||||
} catch {
|
||||
excerpt = '';
|
||||
}
|
||||
recordError('fetch', `${method} ${pathOf(input)} -> ${response.status}${excerpt ? ` ${excerpt}` : ''}`);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
}
|
||||
|
||||
/** Nur fuer Tests: Original-fetch/console.error zurueck, Guard loeschen. Kein Aufrufer im Produktionscode. */
|
||||
export function uninstallErrorBuffer(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
if (!w[GUARD]) return;
|
||||
if (originalFetch) window.fetch = originalFetch;
|
||||
if (originalConsoleError) console.error = originalConsoleError;
|
||||
if (listeners) {
|
||||
window.removeEventListener('error', listeners.error);
|
||||
window.removeEventListener('unhandledrejection', listeners.rejection);
|
||||
}
|
||||
originalFetch = null;
|
||||
originalConsoleError = null;
|
||||
listeners = null;
|
||||
delete w[GUARD];
|
||||
}
|
||||
@@ -483,6 +483,28 @@
|
||||
"dark": "Dunkel",
|
||||
"system": "System"
|
||||
},
|
||||
"bugReport": {
|
||||
"button": "Fehler melden",
|
||||
"title": "Fehler melden",
|
||||
"intro": "Tessera hat gerade ein Bild dieser Seite aufgenommen – es zeigt genau das, was Sie sehen. Bild, Beschreibung, Seite, Version, Browser und die letzten Fehlermeldungen gehen als E-Mail an Ihren Administrator.",
|
||||
"screenshotAlt": "Vorschau des Bildschirmfotos",
|
||||
"screenshotUnavailable": "Kein Bildschirmfoto möglich – die Meldung wird ohne Bild gesendet.",
|
||||
"attachScreenshot": "Bildschirmfoto beifügen",
|
||||
"descriptionLabel": "Was ist passiert?",
|
||||
"descriptionPlaceholder": "Optional: Was haben Sie getan, was haben Sie erwartet, was ist stattdessen geschehen?",
|
||||
"send": "Senden",
|
||||
"sending": "Wird gesendet…",
|
||||
"cancel": "Abbrechen",
|
||||
"close": "Schließen",
|
||||
"sent": "Vielen Dank, die Meldung wurde gesendet.",
|
||||
"errorNotConfigured": "Für Fehlermeldungen ist noch kein Postfach eingerichtet.",
|
||||
"errorNotConfiguredAdminHint": "Legen Sie die Adresse unter Administrator → SMTP im Feld „Fehlermeldungen an\" fest.",
|
||||
"errorNotConfiguredAdminLink": "Zu den SMTP-Einstellungen",
|
||||
"errorTooMany": "Zu viele Meldungen in kurzer Zeit. Bitte versuchen Sie es in einigen Minuten erneut.",
|
||||
"errorTooLarge": "Das Bild ist zu groß. Bitte senden Sie die Meldung ohne Bildschirmfoto.",
|
||||
"errorSendFailed": "Die E-Mail konnte nicht gesendet werden. Bitte versuchen Sie es später erneut oder wenden Sie sich an Ihren Administrator.",
|
||||
"errorGeneric": "Die Meldung konnte nicht gesendet werden."
|
||||
},
|
||||
"locale": {
|
||||
"de": "Deutsch",
|
||||
"en": "English"
|
||||
|
||||
@@ -483,6 +483,28 @@
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
},
|
||||
"bugReport": {
|
||||
"button": "Report a problem",
|
||||
"title": "Report a problem",
|
||||
"intro": "Tessera has just taken a picture of this page – it shows exactly what you see. The picture, your description, the page, version, browser and the most recent error messages are sent by e-mail to your administrator.",
|
||||
"screenshotAlt": "Screenshot preview",
|
||||
"screenshotUnavailable": "No screenshot possible – the report will be sent without a picture.",
|
||||
"attachScreenshot": "Attach screenshot",
|
||||
"descriptionLabel": "What happened?",
|
||||
"descriptionPlaceholder": "Optional: What did you do, what did you expect, what happened instead?",
|
||||
"send": "Send",
|
||||
"sending": "Sending…",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"sent": "Thank you, your report has been sent.",
|
||||
"errorNotConfigured": "No mailbox has been set up for bug reports yet.",
|
||||
"errorNotConfiguredAdminHint": "Set the address under Administrator → SMTP in the field \"Bug reports to\".",
|
||||
"errorNotConfiguredAdminLink": "Go to the SMTP settings",
|
||||
"errorTooMany": "Too many reports in a short time. Please try again in a few minutes.",
|
||||
"errorTooLarge": "The picture is too large. Please send the report without the screenshot.",
|
||||
"errorSendFailed": "The e-mail could not be sent. Please try again later or contact your administrator.",
|
||||
"errorGeneric": "The report could not be sent."
|
||||
},
|
||||
"locale": {
|
||||
"de": "Deutsch",
|
||||
"en": "English"
|
||||
|
||||
@@ -174,4 +174,6 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||
'RSS',
|
||||
'RSSGenerator',
|
||||
'SSL',
|
||||
// 260914-m97: Fehler-melden-Knopf, Pflichtlabel "Was ist passiert?"
|
||||
'passiert',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user