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);
}
}