From d9b94bd25929d90885a6944eebcf89bab610762d Mon Sep 17 00:00:00 2001 From: Schalli Date: Thu, 17 Sep 2026 12:38:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(web):=20Desktop-Client=20per=20Cookie=20er?= =?UTF-8?q?kennen=20=E2=80=94=20Download-Links=20und=20Browser-Kontextmen?= =?UTF-8?q?=C3=BC=20in=20der=20App=20aus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - withDesktopCookie in middleware.ts setzt tessera_desktop=1 auf JEDER Antwort (Fruehausstieg, Redirects, next()), wenn ?desktop=1 anliegt - desktop-client.ts: isDesktopClient() liest das Cookie, useIsDesktopClient() kapselt es hydration-sicher per useEffect - DesktopDownloadLinks fragt /desktop/latest im Desktop-Client gar nicht erst an und rendert nichts - DesktopContextMenuGuard unterdrueckt das WebView2-Kontextmenue ausserhalb von Eingabefeldern/contenteditable, in layout.tsx eingebunden - middleware.test.ts (neu), desktop-client.test.ts (neu), desktop-context-menu-guard.test.tsx (neu), Test 4 in desktop-download-links.test.tsx — alle 417 Web-Tests und type-check gruen Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/app/layout.tsx | 2 + .../desktop-context-menu-guard.test.tsx | 87 +++++++++++++++++++ .../desktop/desktop-context-menu-guard.tsx | 38 ++++++++ .../desktop/desktop-download-links.test.tsx | 22 ++++- .../desktop/desktop-download-links.tsx | 11 ++- apps/web/src/lib/desktop-client.test.ts | 52 +++++++++++ apps/web/src/lib/desktop-client.ts | 34 ++++++++ apps/web/src/middleware.test.ts | 68 +++++++++++++++ apps/web/src/middleware.ts | 40 +++++++-- 9 files changed, 346 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/components/desktop/desktop-context-menu-guard.test.tsx create mode 100644 apps/web/src/components/desktop/desktop-context-menu-guard.tsx create mode 100644 apps/web/src/lib/desktop-client.test.ts create mode 100644 apps/web/src/lib/desktop-client.ts create mode 100644 apps/web/src/middleware.test.ts diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index a40a92f..a4011f7 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next'; import { NextIntlClientProvider } from 'next-intl'; import { getLocale, getMessages } from 'next-intl/server'; import { ThemeProvider } from 'next-themes'; +import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard'; import './globals.css'; export const metadata: Metadata = { @@ -27,6 +28,7 @@ export default async function RootLayout({ disableTransitionOnChange > + {children} diff --git a/apps/web/src/components/desktop/desktop-context-menu-guard.test.tsx b/apps/web/src/components/desktop/desktop-context-menu-guard.test.tsx new file mode 100644 index 0000000..29b86ee --- /dev/null +++ b/apps/web/src/components/desktop/desktop-context-menu-guard.test.tsx @@ -0,0 +1,87 @@ +import { act, cleanup, render } from '@testing-library/react'; +import { afterEach, describe, expect, it } from 'vitest'; +import { DesktopContextMenuGuard } from './desktop-context-menu-guard'; +import { DESKTOP_COOKIE_NAME } from '@/lib/desktop-client'; + +/** + * desktop-context-menu-guard.test (260917-h2s) — unterdrueckt das + * WebView2-Browser-Kontextmenue im Desktop-Client, laesst es in + * Eingabefeldern/contenteditable-Bereichen zu. `fire` gibt den + * `dispatchEvent`-Rueckgabewert zurueck: `false` bedeutet, `preventDefault()` + * wurde aufgerufen. + */ +function setCookie() { + document.cookie = `${DESKTOP_COOKIE_NAME}=1; path=/`; +} + +function clearCookie() { + document.cookie = `${DESKTOP_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; +} + +function fire(el: Element) { + return el.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true })); +} + +afterEach(() => { + cleanup(); + clearCookie(); +}); + +describe('DesktopContextMenuGuard', () => { + it('mit Cookie: unterdrueckt ausserhalb von Eingabefeldern, laesst sie darin zu', async () => { + setCookie(); + render(); + await act(async () => {}); + + const div = document.createElement('div'); + document.body.appendChild(div); + expect(fire(div)).toBe(false); + + const input = document.createElement('input'); + document.body.appendChild(input); + expect(fire(input)).toBe(true); + + const textarea = document.createElement('textarea'); + document.body.appendChild(textarea); + expect(fire(textarea)).toBe(true); + + const select = document.createElement('select'); + document.body.appendChild(select); + expect(fire(select)).toBe(true); + + const editable = document.createElement('div'); + editable.setAttribute('contenteditable', 'true'); + const span = document.createElement('span'); + editable.appendChild(span); + document.body.appendChild(editable); + expect(fire(span)).toBe(true); + + document.body.removeChild(div); + document.body.removeChild(input); + document.body.removeChild(textarea); + document.body.removeChild(select); + document.body.removeChild(editable); + }); + + it('ohne Cookie: Kontextmenue bleibt erlaubt', async () => { + render(); + await act(async () => {}); + + const div = document.createElement('div'); + document.body.appendChild(div); + expect(fire(div)).toBe(true); + document.body.removeChild(div); + }); + + it('nach unmount: Kontextmenue bleibt wieder erlaubt', async () => { + setCookie(); + const { unmount } = render(); + await act(async () => {}); + unmount(); + + const div = document.createElement('div'); + document.body.appendChild(div); + expect(fire(div)).toBe(true); + document.body.removeChild(div); + }); +}); diff --git a/apps/web/src/components/desktop/desktop-context-menu-guard.tsx b/apps/web/src/components/desktop/desktop-context-menu-guard.tsx new file mode 100644 index 0000000..9c0bc99 --- /dev/null +++ b/apps/web/src/components/desktop/desktop-context-menu-guard.tsx @@ -0,0 +1,38 @@ +'use client'; + +import { useEffect } from 'react'; +import { useIsDesktopClient } from '@/lib/desktop-client'; + +/** + * DesktopContextMenuGuard (260917-h2s) — unterdrueckt das + * WebView2-Browser-Kontextmenue (Zurueck, Aktualisieren, Speichern unter, + * Drucken …) im Desktop-Client; in Eingabefeldern und contenteditable- + * Bereichen bleibt es erhalten, damit Kopieren/Einfuegen erreichbar bleibt. + * jsdom kennt `isContentEditable` nicht -- deshalb zusaetzlich der Selektor. + */ +const EDITABLE_SELECTOR = + 'input, textarea, select, [contenteditable=""], [contenteditable="true"], [contenteditable="plaintext-only"]'; + +export function DesktopContextMenuGuard() { + const isDesktop = useIsDesktopClient(); + + useEffect(() => { + if (!isDesktop) return; + + const handler = (event: MouseEvent) => { + const target = event.target; + if (!(target instanceof Element)) return; + if (target.closest(EDITABLE_SELECTOR) || (target as HTMLElement).isContentEditable === true) { + return; + } + event.preventDefault(); + }; + + document.addEventListener('contextmenu', handler); + return () => { + document.removeEventListener('contextmenu', handler); + }; + }, [isDesktop]); + + return null; +} diff --git a/apps/web/src/components/desktop/desktop-download-links.test.tsx b/apps/web/src/components/desktop/desktop-download-links.test.tsx index 04365dd..759aeb5 100644 --- a/apps/web/src/components/desktop/desktop-download-links.test.tsx +++ b/apps/web/src/components/desktop/desktop-download-links.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen } from '@testing-library/react'; +import { act, cleanup, render, screen } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { DesktopDownloadLinks } from './desktop-download-links'; @@ -35,9 +35,14 @@ vi.mock('@/lib/desktop', async (importOriginal) => { }; }); +function clearDesktopCookie() { + document.cookie = 'tessera_desktop=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/'; +} + afterEach(() => { cleanup(); vi.clearAllMocks(); + clearDesktopCookie(); }); const windowsFile = { @@ -94,4 +99,19 @@ describe('DesktopDownloadLinks (18-03)', () => { expect(linuxLink.closest('a')).toHaveAttribute('href', 'http://localhost:3001/desktop/download/linux'); expect(screen.queryByText('Linux-Version')).not.toBeInTheDocument(); }); + + it('Test 4 (im Desktop-Client): rendert nichts und fragt /desktop/latest gar nicht erst an', async () => { + document.cookie = 'tessera_desktop=1; path=/'; + loadDesktopLatest.mockResolvedValue({ + version: '1.1.0', + channel: 'beta', + commit: 'abc1234', + buildTime: 'x', + files: { windows: windowsFile, linux: linuxFile }, + }); + const { container } = render(); + await act(async () => {}); + expect(loadDesktopLatest).not.toHaveBeenCalled(); + expect(container.firstChild).toBeNull(); + }); }); diff --git a/apps/web/src/components/desktop/desktop-download-links.tsx b/apps/web/src/components/desktop/desktop-download-links.tsx index 7464201..56cd892 100644 --- a/apps/web/src/components/desktop/desktop-download-links.tsx +++ b/apps/web/src/components/desktop/desktop-download-links.tsx @@ -3,18 +3,23 @@ import { useEffect, useState } from 'react'; import { useTranslations } from 'next-intl'; import { desktopDownloadUrl, type DesktopLatestInfo, loadDesktopLatest } from '@/lib/desktop'; +import { isDesktopClient, useIsDesktopClient } from '@/lib/desktop-client'; /** * Unauffaelliger Download-Link auf der Anmeldeseite (D-12): erscheint nur, * wenn `/desktop/latest` antwortet — kein Fehlertext, kein Ladeanzeiger, * nichts, solange nichts geladen ist. Windows fuehrt (Hauptlink), Linux - * folgt als kleiner zweiter Link, wenn beide Pakete vorliegen. + * folgt als kleiner zweiter Link, wenn beide Pakete vorliegen. Im + * Desktop-Client entfaellt der Block komplett (260917-h2s) -- die Kennung + * kommt ueber das Cookie `tessera_desktop`. */ export function DesktopDownloadLinks() { const t = useTranslations('auth'); const [info, setInfo] = useState(null); + const isDesktop = useIsDesktopClient(); useEffect(() => { + if (isDesktopClient()) return; let active = true; loadDesktopLatest().then((data) => { if (active) setInfo(data); @@ -24,6 +29,10 @@ export function DesktopDownloadLinks() { }; }, []); + if (isDesktop) { + return null; + } + const files = info?.files; const windows = files?.windows; const linux = files?.linux; diff --git a/apps/web/src/lib/desktop-client.test.ts b/apps/web/src/lib/desktop-client.test.ts new file mode 100644 index 0000000..9486ac0 --- /dev/null +++ b/apps/web/src/lib/desktop-client.test.ts @@ -0,0 +1,52 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DESKTOP_COOKIE_NAME, isDesktopClient, useIsDesktopClient } from './desktop-client'; + +/** + * desktop-client.test — Gegenstueck zur Middleware (260917-h2s): liest das + * Cookie `tessera_desktop`, das die Middleware auf jede Antwort setzt, wenn + * der Desktop-Client `?desktop=1` an seine erste Navigation anhaengt. + */ +function clearCookie() { + document.cookie = `${DESKTOP_COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; +} + +afterEach(() => { + clearCookie(); +}); + +describe('isDesktopClient', () => { + it('ohne Cookie: false', () => { + expect(isDesktopClient()).toBe(false); + }); + + it('mit tessera_desktop=1: true', () => { + document.cookie = `${DESKTOP_COOKIE_NAME}=1; path=/`; + expect(isDesktopClient()).toBe(true); + }); + + it('mit tessera_desktop=0: false', () => { + document.cookie = `${DESKTOP_COOKIE_NAME}=0; path=/`; + expect(isDesktopClient()).toBe(false); + }); + + it('ohne document: false', () => { + vi.stubGlobal('document', undefined); + expect(isDesktopClient()).toBe(false); + vi.unstubAllGlobals(); + }); +}); + +describe('useIsDesktopClient', () => { + it('Cookie gesetzt: liefert nach dem Effekt true', async () => { + document.cookie = `${DESKTOP_COOKIE_NAME}=1; path=/`; + const { result } = renderHook(() => useIsDesktopClient()); + await waitFor(() => expect(result.current).toBe(true)); + }); + + it('ohne Cookie: bleibt false', async () => { + const { result } = renderHook(() => useIsDesktopClient()); + await act(async () => {}); + expect(result.current).toBe(false); + }); +}); diff --git a/apps/web/src/lib/desktop-client.ts b/apps/web/src/lib/desktop-client.ts new file mode 100644 index 0000000..5860aac --- /dev/null +++ b/apps/web/src/lib/desktop-client.ts @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; + +/** + * desktop-client — erkennt, ob die Seite im Desktop-Client laeuft + * (260917-h2s). Gegenstueck zur Middleware (apps/web/src/middleware.ts, + * `withDesktopCookie`): der Rust-Client haengt `desktop=1` an seine erste + * Navigation, die Middleware setzt daraufhin das Cookie `tessera_desktop` + * auf jede Antwort. `isDesktopClient()` liest das Cookie synchron. + */ +export const DESKTOP_COOKIE_NAME = 'tessera_desktop'; + +export function isDesktopClient(): boolean { + if (typeof document === 'undefined') { + return false; + } + return document.cookie + .split(';') + .some((c) => c.trim() === `${DESKTOP_COOKIE_NAME}=1`); +} + +/** + * Hook statt direktem Aufruf beim Render: `isDesktopClient()` synchron im + * ersten Render zu nutzen wuerde Server-HTML (kein `document`) und den + * ersten Client-Render auseinanderlaufen lassen -- Hydration-Fehler. Der + * `useEffect` gleicht das an; `useState(false)` haelt Server- und + * Erst-Render-Ergebnis identisch. + */ +export function useIsDesktopClient(): boolean { + const [isDesktop, setIsDesktop] = useState(false); + useEffect(() => { + setIsDesktop(isDesktopClient()); + }, []); + return isDesktop; +} diff --git a/apps/web/src/middleware.test.ts b/apps/web/src/middleware.test.ts new file mode 100644 index 0000000..901e1b9 --- /dev/null +++ b/apps/web/src/middleware.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment node +import { NextRequest } from 'next/server'; +import { SignJWT } from 'jose'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { middleware } from './middleware'; + +/** + * middleware.test — Desktop-Client-Cookie (260917-h2s), eigener describe-Block + * neben den bestehenden Redirect-/Session-Faellen. `@vitest-environment node`, + * weil `NextRequest`/`NextResponse` node-typische APIs (Headers, URL) nutzen, + * die im jsdom-Standardmilieu der Suite nicht gebraucht werden. + */ +describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => { + beforeEach(() => { + vi.stubEnv('JWT_SECRET', 'test-secret'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('Test 1: /login?desktop=1 setzt das Cookie tessera_desktop=1', async () => { + const req = new NextRequest('http://localhost:3000/login?desktop=1'); + const res = await middleware(req); + const setCookie = res.headers.get('set-cookie'); + expect(setCookie).toContain('tessera_desktop=1'); + expect(setCookie).toContain('Path=/'); + expect(setCookie).toContain('Max-Age=31536000'); + expect(setCookie).toContain('SameSite=lax'); + expect(setCookie).not.toContain('Secure'); + expect(setCookie).not.toContain('HttpOnly'); + }); + + it('Test 2: /login ohne Parameter setzt kein Cookie', async () => { + const req = new NextRequest('http://localhost:3000/login'); + const res = await middleware(req); + expect(res.headers.get('set-cookie')).toBeNull(); + }); + + it('Test 3: /dashboard?desktop=1 ohne Session leitet um und setzt das Cookie', async () => { + const req = new NextRequest('http://localhost:3000/dashboard?desktop=1'); + const res = await middleware(req); + expect(res.status).toBe(307); + expect(res.headers.get('location')).toContain('/login'); + expect(res.headers.get('set-cookie')).toContain('tessera_desktop=1'); + }); + + it('Test 4: https setzt Secure', async () => { + const req = new NextRequest('https://tessera.example.com/login?desktop=1'); + const res = await middleware(req); + expect(res.headers.get('set-cookie')).toContain('Secure'); + }); + + it('Test 5: gueltiges JWT laesst die Anfrage durch und setzt trotzdem das Cookie', async () => { + const token = await new SignJWT({ sub: 'u1' }) + .setProtectedHeader({ alg: 'HS256' }) + .setIssuedAt() + .setExpirationTime('5m') + .sign(new TextEncoder().encode('test-secret')); + + const req = new NextRequest('http://localhost:3000/dashboard?desktop=1', { + headers: { cookie: `session=${token}` }, + }); + const res = await middleware(req); + expect(res.headers.get('set-cookie')).toContain('tessera_desktop=1'); + expect(res.headers.get('x-middleware-next')).toBe('1'); + }); +}); diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index 5a55670..dc6e7ad 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -12,6 +12,31 @@ import { buildNextParam } from '@/lib/safe-next'; const publicRoutes = ['/login', '/reset-password']; +const DESKTOP_COOKIE = 'tessera_desktop'; + +/** + * Setzt das Cookie `tessera_desktop`, wenn die Anfrage `?desktop=1` traegt + * (260917-h2s). Der Desktop-Client (apps/desktop/src-tauri/src/lib.rs, + * `with_desktop_marker`) haengt den Parameter nur an seine ERSTE Navigation + * an; das Cookie muss deshalb auf JEDER Antwort landen, auch auf dem + * Fruehausstieg fuer oeffentliche Routen und auf Redirects -- sonst geht die + * Kennung beim 307 nach /login verloren. `httpOnly: false` ist Absicht (wird + * von `isDesktopClient()` in apps/web/src/lib/desktop-client.ts gelesen); + * der Wert ist kein Geheimnis. + */ +function withDesktopCookie(req: NextRequest, res: NextResponse): NextResponse { + if (req.nextUrl.searchParams.get('desktop') === '1') { + res.cookies.set(DESKTOP_COOKIE, '1', { + path: '/', + maxAge: 60 * 60 * 24 * 365, + sameSite: 'lax', + httpOnly: false, + secure: req.nextUrl.protocol === 'https:', + }); + } + return res; +} + /** * Umleitung zur Anmeldeseite mit `next`-Parameter (quick-260917-gyd): Pfad * + Query der urspruenglich angeforderten Seite wandern mit, damit die @@ -42,7 +67,7 @@ export async function middleware(req: NextRequest) { // Allow public routes without authentication if (publicRoutes.some((route) => path.startsWith(route))) { - return NextResponse.next(); + return withDesktopCookie(req, NextResponse.next()); } // Skip static assets and API routes (handled by NestJS) @@ -52,14 +77,14 @@ export async function middleware(req: NextRequest) { path.startsWith('/favicon.ico') || path.startsWith('/api') ) { - return NextResponse.next(); + return withDesktopCookie(req, NextResponse.next()); } // Read session cookie const session = req.cookies.get('session')?.value; if (!session) { - return redirectToLogin(req); + return withDesktopCookie(req, redirectToLogin(req)); } try { @@ -72,15 +97,18 @@ export async function middleware(req: NextRequest) { payload.mustChangePassword === true && !path.startsWith('/change-password') ) { - return NextResponse.redirect(new URL('/change-password', req.nextUrl)); + return withDesktopCookie( + req, + NextResponse.redirect(new URL('/change-password', req.nextUrl)), + ); } - return NextResponse.next(); + return withDesktopCookie(req, NextResponse.next()); } catch { // JWT verification failed -- clear stale cookie and redirect to login const response = redirectToLogin(req); response.cookies.delete('session'); - return response; + return withDesktopCookie(req, response); } }