feat(web): Desktop-Client per Cookie erkennen — Download-Links und Browser-Kontextmenü in der App aus

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-09-17 12:38:13 +02:00
parent 5bdabf558b
commit d9b94bd259
9 changed files with 346 additions and 8 deletions
+34 -6
View File
@@ -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);
}
}