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:
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
|
|||||||
import { NextIntlClientProvider } from 'next-intl';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
import { getLocale, getMessages } from 'next-intl/server';
|
import { getLocale, getMessages } from 'next-intl/server';
|
||||||
import { ThemeProvider } from 'next-themes';
|
import { ThemeProvider } from 'next-themes';
|
||||||
|
import { DesktopContextMenuGuard } from '@/components/desktop/desktop-context-menu-guard';
|
||||||
import './globals.css';
|
import './globals.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -27,6 +28,7 @@ export default async function RootLayout({
|
|||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
<DesktopContextMenuGuard />
|
||||||
{children}
|
{children}
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
@@ -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(<DesktopContextMenuGuard />);
|
||||||
|
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(<DesktopContextMenuGuard />);
|
||||||
|
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(<DesktopContextMenuGuard />);
|
||||||
|
await act(async () => {});
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
const div = document.createElement('div');
|
||||||
|
document.body.appendChild(div);
|
||||||
|
expect(fire(div)).toBe(true);
|
||||||
|
document.body.removeChild(div);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { DesktopDownloadLinks } from './desktop-download-links';
|
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(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
clearDesktopCookie();
|
||||||
});
|
});
|
||||||
|
|
||||||
const windowsFile = {
|
const windowsFile = {
|
||||||
@@ -94,4 +99,19 @@ describe('DesktopDownloadLinks (18-03)', () => {
|
|||||||
expect(linuxLink.closest('a')).toHaveAttribute('href', 'http://localhost:3001/desktop/download/linux');
|
expect(linuxLink.closest('a')).toHaveAttribute('href', 'http://localhost:3001/desktop/download/linux');
|
||||||
expect(screen.queryByText('Linux-Version')).not.toBeInTheDocument();
|
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(<DesktopDownloadLinks />);
|
||||||
|
await act(async () => {});
|
||||||
|
expect(loadDesktopLatest).not.toHaveBeenCalled();
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,18 +3,23 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { desktopDownloadUrl, type DesktopLatestInfo, loadDesktopLatest } from '@/lib/desktop';
|
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,
|
* Unauffaelliger Download-Link auf der Anmeldeseite (D-12): erscheint nur,
|
||||||
* wenn `/desktop/latest` antwortet — kein Fehlertext, kein Ladeanzeiger,
|
* wenn `/desktop/latest` antwortet — kein Fehlertext, kein Ladeanzeiger,
|
||||||
* nichts, solange nichts geladen ist. Windows fuehrt (Hauptlink), Linux
|
* 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() {
|
export function DesktopDownloadLinks() {
|
||||||
const t = useTranslations('auth');
|
const t = useTranslations('auth');
|
||||||
const [info, setInfo] = useState<DesktopLatestInfo | null>(null);
|
const [info, setInfo] = useState<DesktopLatestInfo | null>(null);
|
||||||
|
const isDesktop = useIsDesktopClient();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isDesktopClient()) return;
|
||||||
let active = true;
|
let active = true;
|
||||||
loadDesktopLatest().then((data) => {
|
loadDesktopLatest().then((data) => {
|
||||||
if (active) setInfo(data);
|
if (active) setInfo(data);
|
||||||
@@ -24,6 +29,10 @@ export function DesktopDownloadLinks() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (isDesktop) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const files = info?.files;
|
const files = info?.files;
|
||||||
const windows = files?.windows;
|
const windows = files?.windows;
|
||||||
const linux = files?.linux;
|
const linux = files?.linux;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,31 @@ import { buildNextParam } from '@/lib/safe-next';
|
|||||||
|
|
||||||
const publicRoutes = ['/login', '/reset-password'];
|
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
|
* Umleitung zur Anmeldeseite mit `next`-Parameter (quick-260917-gyd): Pfad
|
||||||
* + Query der urspruenglich angeforderten Seite wandern mit, damit die
|
* + Query der urspruenglich angeforderten Seite wandern mit, damit die
|
||||||
@@ -42,7 +67,7 @@ export async function middleware(req: NextRequest) {
|
|||||||
|
|
||||||
// Allow public routes without authentication
|
// Allow public routes without authentication
|
||||||
if (publicRoutes.some((route) => path.startsWith(route))) {
|
if (publicRoutes.some((route) => path.startsWith(route))) {
|
||||||
return NextResponse.next();
|
return withDesktopCookie(req, NextResponse.next());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip static assets and API routes (handled by NestJS)
|
// 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('/favicon.ico') ||
|
||||||
path.startsWith('/api')
|
path.startsWith('/api')
|
||||||
) {
|
) {
|
||||||
return NextResponse.next();
|
return withDesktopCookie(req, NextResponse.next());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read session cookie
|
// Read session cookie
|
||||||
const session = req.cookies.get('session')?.value;
|
const session = req.cookies.get('session')?.value;
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
return redirectToLogin(req);
|
return withDesktopCookie(req, redirectToLogin(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -72,15 +97,18 @@ export async function middleware(req: NextRequest) {
|
|||||||
payload.mustChangePassword === true &&
|
payload.mustChangePassword === true &&
|
||||||
!path.startsWith('/change-password')
|
!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 {
|
} catch {
|
||||||
// JWT verification failed -- clear stale cookie and redirect to login
|
// JWT verification failed -- clear stale cookie and redirect to login
|
||||||
const response = redirectToLogin(req);
|
const response = redirectToLogin(req);
|
||||||
response.cookies.delete('session');
|
response.cookies.delete('session');
|
||||||
return response;
|
return withDesktopCookie(req, response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user