Compare commits
2 Commits
32441d77c7
...
31d514b7ca
| Author | SHA1 | Date | |
|---|---|---|---|
| 31d514b7ca | |||
| 52f538c432 |
@@ -15,6 +15,8 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
|
|||||||
|
|
||||||
### Behoben
|
### Behoben
|
||||||
|
|
||||||
|
- Anmeldung: Wer schon angemeldet ist und die Anmeldeseite aufruft, landet jetzt direkt auf dem Dashboard.
|
||||||
|
- Willkommensmail: Logo und Schriftzug erscheinen jetzt in jedem Mailprogramm. Bisher steckten sie in einem Bild; zeigte Outlook es nicht an, blieb nur ein großer schwarzer Kasten. Die Welle darunter ist nur noch ein schmaler Streifen.
|
||||||
- Anmeldung: Eine geänderte Rolle, eine Deaktivierung oder das Löschen eines Kontos wirkt jetzt sofort. Bisher galt bis zu 30 Tage die Rolle vom Zeitpunkt der Anmeldung weiter – ein herabgestufter Administrator behielt seine Rechte, ein deaktiviertes Konto konnte mit seiner Sitzung weiterarbeiten, und die Benutzerliste ließ sich nach einer Rollenänderung nicht laden.
|
- Anmeldung: Eine geänderte Rolle, eine Deaktivierung oder das Löschen eines Kontos wirkt jetzt sofort. Bisher galt bis zu 30 Tage die Rolle vom Zeitpunkt der Anmeldung weiter – ein herabgestufter Administrator behielt seine Rechte, ein deaktiviertes Konto konnte mit seiner Sitzung weiterarbeiten, und die Benutzerliste ließ sich nach einer Rollenänderung nicht laden.
|
||||||
|
|
||||||
## 1.8.0 – 2026-09-30
|
## 1.8.0 – 2026-09-30
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 10 KiB |
@@ -1,28 +1,24 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="300" viewBox="0 0 1200 300">
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="112" viewBox="0 0 1200 112">
|
||||||
<!--
|
<!--
|
||||||
Kopfbild der Tessera-Systemmails (Willkommensmail). Quelle des PNG
|
Wellenstreifen unter dem Kopf der Tessera-Systemmails (Willkommensmail).
|
||||||
daneben (welcome-header.png, 1200x300, angezeigt 600x150); erzeugt mit
|
Quelle des PNG daneben (welcome-header.png, 1200x112, angezeigt 600x56);
|
||||||
`node apps/api/scripts/render-mail-header.mjs`.
|
erzeugt mit `node apps/api/scripts/render-mail-header.mjs`.
|
||||||
|
|
||||||
Motiv: der Dashboard-Hintergrund "Duenen" in seiner dunklen Fassung
|
Seit quick-260930 (Rueckmeldung des Nutzers: in Outlook "ein riesiger
|
||||||
(apps/web/src/lib/dashboard-background.ts, dunes('#1a1c20', '#23262b',
|
schwarzer Fleck, kein Logo") steckt KEIN Logo und KEIN Text mehr im Bild:
|
||||||
'#2c3036', '#363a41')), auf das flache Mail-Format 4:1 umgelegt, mit
|
Bildmarke und Schriftzug stehen als HTML im Mailkopf und erscheinen immer.
|
||||||
einer feinen gelben Wellenlinie als Akzent; links die Bildmarke
|
Dieses Bild ist nur noch Schmuck: oben die Kopffarbe, darunter die
|
||||||
(apps/web/src/app/icon.svg, Grundplatte mit heller Kontur wie auf der
|
Duenen-Wellen des Dashboard-Hintergrunds (dunkle Fassung,
|
||||||
dunklen App-Leiste) und der Schriftzug.
|
apps/web/src/lib/dashboard-background.ts) mit der feinen gelben Linie,
|
||||||
|
unten laeuft es ins Weiss der Karte aus. Zeigt ein Mailprogramm das Bild
|
||||||
|
nicht, bleibt dort nur weisser Abstand.
|
||||||
-->
|
-->
|
||||||
<rect width="1200" height="300" fill="#1a1c20"/>
|
<rect width="1200" height="112" fill="#ffffff"/>
|
||||||
<path d="M0 176 C230 136 470 222 700 190 S1060 118 1200 160 L1200 300 L0 300Z" fill="#23262b"/>
|
<!-- Flaechen von unten nach oben uebereinander, jede von oben bis zu ihrer
|
||||||
<path d="M0 176 C230 136 470 222 700 190 S1060 118 1200 160" fill="none" stroke="#ffed00" stroke-opacity="0.55" stroke-width="3"/>
|
Wellenlinie: so teilen sich benachbarte Baender dieselbe Kante, ohne
|
||||||
<path d="M0 230 C270 196 500 266 780 232 S1090 190 1200 214 L1200 300 L0 300Z" fill="#2c3036"/>
|
Luecken dazwischen. -->
|
||||||
<path d="M0 272 C300 250 600 296 900 268 S1130 252 1200 260 L1200 300 L0 300Z" fill="#363a41"/>
|
<path d="M0 0 V92 C220 78 420 102 600 96 S1000 76 1200 86 V0 Z" fill="#d9dce0"/>
|
||||||
<g transform="translate(60 40) scale(1.5)">
|
<path d="M0 0 V70 C250 50 420 86 640 76 S1040 50 1200 64 V0 Z" fill="#2c3036"/>
|
||||||
<rect x="4" y="4" width="64" height="64" rx="14" fill="#1a1a1a" stroke="#ffffff" stroke-opacity="0.22" stroke-width="2"/>
|
<path d="M0 0 V44 C330 18 520 62 700 52 S1060 22 1200 40 V0 Z" fill="#1a1c20"/>
|
||||||
<rect x="16" y="16" width="12" height="12" rx="2.5" fill="#9c9440"/>
|
<path d="M0 44 C330 18 520 62 700 52 S1060 22 1200 40" fill="none" stroke="#ffed00" stroke-opacity="0.75" stroke-width="3"/>
|
||||||
<rect x="30" y="16" width="12" height="12" rx="2.5" fill="#9c9440"/>
|
|
||||||
<rect x="45" y="15" width="12" height="12" rx="2.5" transform="rotate(12 51 21)" fill="#ffed00"/>
|
|
||||||
<rect x="30" y="30" width="12" height="12" rx="2.5" fill="#9c9440"/>
|
|
||||||
<rect x="30" y="44" width="12" height="12" rx="2.5" fill="#9c9440"/>
|
|
||||||
</g>
|
|
||||||
<text x="192" y="118" fill="#ffffff" font-family="'Segoe UI', Inter, 'Noto Sans', 'DejaVu Sans', sans-serif" font-size="60" font-weight="700" letter-spacing="-1">Tessera</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,8 +1,8 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
/**
|
/**
|
||||||
* render-mail-header.mjs — erzeugt das Kopfbild der Willkommensmail
|
* render-mail-header.mjs — erzeugt das Kopfbild der Willkommensmail
|
||||||
* (apps/api/assets/mail/welcome-header.png, 1200x300 fuer hochaufloesende
|
* (apps/api/assets/mail/welcome-header.png, 1200x112 fuer hochaufloesende
|
||||||
* Bildschirme, in der Mail 600x150 angezeigt) aus der daneben liegenden
|
* Bildschirme, in der Mail 600x56 angezeigt) aus der daneben liegenden
|
||||||
* Quelle welcome-header.svg.
|
* Quelle welcome-header.svg.
|
||||||
*
|
*
|
||||||
* Warum ein PNG statt Inline-SVG oder CSS-Hintergrund: Outlook (Word-
|
* Warum ein PNG statt Inline-SVG oder CSS-Hintergrund: Outlook (Word-
|
||||||
@@ -33,7 +33,7 @@ const sharp = createRequire(nextPkg)('sharp');
|
|||||||
|
|
||||||
const svg = readFileSync(join(assetDir, 'welcome-header.svg'));
|
const svg = readFileSync(join(assetDir, 'welcome-header.svg'));
|
||||||
const png = await sharp(svg, { density: 72 })
|
const png = await sharp(svg, { density: 72 })
|
||||||
.resize(1200, 300)
|
.resize(1200, 112)
|
||||||
.png({ compressionLevel: 9, palette: false })
|
.png({ compressionLevel: 9, palette: false })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
writeFileSync(join(assetDir, 'welcome-header.png'), png);
|
writeFileSync(join(assetDir, 'welcome-header.png'), png);
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
* Mail sehr unterschiedlich darstellen:
|
* Mail sehr unterschiedlich darstellen:
|
||||||
* - Tabellenlayout, alle Stile inline, hoechstens 600 px breit;
|
* - Tabellenlayout, alle Stile inline, hoechstens 600 px breit;
|
||||||
* - keine externen Ressourcen, keine Web-Fonts (Systemschriften);
|
* - keine externen Ressourcen, keine Web-Fonts (Systemschriften);
|
||||||
* - der Kopf im Stil des Dashboard-Hintergrunds "Duenen" ist ein PNG
|
* - Kopf: Bildmarke und Schriftzug als HTML (erscheinen immer), darunter
|
||||||
* (`headerImageSrc`, im Versand `cid:`), weil SVG und CSS-Hintergruende in
|
* die Duenen-Welle als schmaler PNG-Streifen (`headerImageSrc`, im
|
||||||
* Outlook nicht erscheinen; fehlt das Bild, steht dort ein dunkler
|
* Versand `cid:`), weil SVG und CSS-Hintergruende in Outlook nicht
|
||||||
* Textkopf;
|
* erscheinen; fehlt das Bild, bleibt nur ein schmaler Abschluss;
|
||||||
* - Knoepfe als Tabelle mit Hintergrundfarbe in der Zelle ("bulletproof"),
|
* - Knoepfe als Tabelle mit Hintergrundfarbe in der Zelle ("bulletproof"),
|
||||||
* Outlook ignoriert Innenabstaende und Rundungen am Link selbst.
|
* Outlook ignoriert Innenabstaende und Rundungen am Link selbst.
|
||||||
*
|
*
|
||||||
@@ -118,13 +118,58 @@ function button(href: string, label: string, bg: string, fg: string): string {
|
|||||||
</td></tr></table>`;
|
</td></tr></table>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function headerRow(src: string | null): string {
|
/** Eine Kachel der Bildmarke: feste Zelle, Hoehe auch in Outlook exakt. */
|
||||||
if (src) {
|
function tile(color: string | null): string {
|
||||||
return `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;line-height:0;font-size:0;">
|
const bg = color ? `bgcolor="${color}" style="background-color:${color};` : 'style="';
|
||||||
<img src="${escapeHtml(src)}" width="600" height="150" alt="Tessera" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none;border-radius:12px 12px 0 0;color:#ffffff;font-family:${FONT};font-size:28px;font-weight:700;">
|
return `<td width="9" height="9" ${bg}width:9px;height:9px;font-size:1px;line-height:9px;mso-line-height-rule:exactly;"> </td>`;
|
||||||
</td></tr>`;
|
|
||||||
}
|
}
|
||||||
return `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;padding:40px 40px 36px;font-family:${FONT};font-size:30px;line-height:36px;font-weight:700;color:#ffffff;border-bottom:3px solid ${C.yellow};">Tessera</td></tr>`;
|
|
||||||
|
/** Luecke zwischen Kacheln. */
|
||||||
|
const GAP = '<td width="3" style="width:3px;font-size:1px;line-height:1px;"> </td>';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bildmarke als HTML (quick-260930): das Kachel-"T" aus Tabellenzellen —
|
||||||
|
* oben drei Kacheln (die dritte gelb, im Original gedreht), darunter zwei in
|
||||||
|
* der Mitte —, auf dunkler Grundplatte mit heller Kontur wie in der App.
|
||||||
|
* Braucht kein Bild und erscheint deshalb in jedem Mailprogramm.
|
||||||
|
*/
|
||||||
|
function logoMark(): string {
|
||||||
|
const row = (cells: Array<string | null>) =>
|
||||||
|
`<tr>${cells.map((c, i) => (i > 0 ? GAP : '') + tile(c)).join('')}</tr>`;
|
||||||
|
const spacer = `<tr><td colspan="5" height="3" style="height:3px;font-size:1px;line-height:3px;mso-line-height-rule:exactly;"> </td></tr>`;
|
||||||
|
const olive = '#9c9440';
|
||||||
|
return `<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:separate;">
|
||||||
|
<tr><td bgcolor="#111214" style="background-color:#111214;border:1px solid #3a3d44;border-radius:10px;padding:10px 10px 10px 10px;">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">
|
||||||
|
${row([olive, olive, C.yellow])}${spacer}${row([null, olive, null])}${spacer}${row([null, olive, null])}
|
||||||
|
</table>
|
||||||
|
</td></tr></table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kopf der Mail (quick-260930, Rueckmeldung des Nutzers: in Outlook "ein
|
||||||
|
* riesiger schwarzer Fleck, kein Logo"). Vorher steckten Logo und
|
||||||
|
* Schriftzug in EINEM 150 px hohen Bild; zeigt ein Mailprogramm das
|
||||||
|
* eingebettete Bild nicht an, blieb nur die dunkle Flaeche. Jetzt:
|
||||||
|
* - Bildmarke (HTML-Kacheln) und Schriftzug "Tessera" (echter Text) in einer
|
||||||
|
* niedrigen dunklen Leiste — erscheinen immer;
|
||||||
|
* - darunter die Duenen-Welle als schmaler Bildstreifen (`src`, im Versand
|
||||||
|
* `cid:`), der ins Weiss der Karte auslaeuft. Fehlt er, bleibt nur weisser
|
||||||
|
* Abstand, kein dunkler Block.
|
||||||
|
*/
|
||||||
|
function headerRow(src: string | null): string {
|
||||||
|
const bar = `<tr><td bgcolor="${C.header}" style="background-color:${C.header};border-radius:12px 12px 0 0;padding:22px 32px 14px;">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr>
|
||||||
|
<td valign="middle" style="padding:0 14px 0 0;">${logoMark()}</td>
|
||||||
|
<td valign="middle" style="font-family:${FONT};font-size:26px;line-height:32px;font-weight:700;color:#ffffff;letter-spacing:-0.5px;">Tessera</td>
|
||||||
|
</tr></table>
|
||||||
|
</td></tr>`;
|
||||||
|
const wave = src
|
||||||
|
? `<tr><td bgcolor="${C.card}" style="background-color:${C.card};line-height:0;font-size:0;">
|
||||||
|
<img src="${escapeHtml(src)}" width="600" height="56" alt="" style="display:block;width:100%;max-width:600px;height:auto;border:0;outline:none;text-decoration:none;">
|
||||||
|
</td></tr>`
|
||||||
|
: `<tr><td bgcolor="${C.header}" height="4" style="background-color:${C.header};height:4px;font-size:1px;line-height:4px;border-bottom:3px solid ${C.yellow};"> </td></tr>`;
|
||||||
|
return bar + wave;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail {
|
export function renderWelcomeMail(input: WelcomeMailInput): RenderedWelcomeMail {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
import { NextRequest } from 'next/server';
|
|
||||||
import { SignJWT } from 'jose';
|
import { SignJWT } from 'jose';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { middleware } from './middleware';
|
import { middleware } from './middleware';
|
||||||
|
|
||||||
@@ -67,7 +68,9 @@ describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Test 6 (quick-260918-gza): /login mit dv/dc/dos setzt zusaetzlich tessera_desktop_client', async () => {
|
it('Test 6 (quick-260918-gza): /login mit dv/dc/dos setzt zusaetzlich tessera_desktop_client', async () => {
|
||||||
const req = new NextRequest('http://localhost:3000/login?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows');
|
const req = new NextRequest(
|
||||||
|
'http://localhost:3000/login?desktop=1&dv=1.2.0&dc=a6d1a64&dos=windows',
|
||||||
|
);
|
||||||
const res = await middleware(req);
|
const res = await middleware(req);
|
||||||
expect(res.cookies.get('tessera_desktop')?.value).toBe('1');
|
expect(res.cookies.get('tessera_desktop')?.value).toBe('1');
|
||||||
expect(res.cookies.get('tessera_desktop_client')?.value).toBe('1.2.0|a6d1a64|windows');
|
expect(res.cookies.get('tessera_desktop_client')?.value).toBe('1.2.0|a6d1a64|windows');
|
||||||
@@ -98,15 +101,23 @@ describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => {
|
|||||||
);
|
);
|
||||||
expect((await middleware(spaceInOs)).cookies.get('tessera_desktop_client')).toBeUndefined();
|
expect((await middleware(spaceInOs)).cookies.get('tessera_desktop_client')).toBeUndefined();
|
||||||
|
|
||||||
const missingDv = new NextRequest('http://localhost:3000/login?desktop=1&dc=a6d1a64&dos=windows');
|
const missingDv = new NextRequest(
|
||||||
|
'http://localhost:3000/login?desktop=1&dc=a6d1a64&dos=windows',
|
||||||
|
);
|
||||||
expect((await middleware(missingDv)).cookies.get('tessera_desktop_client')).toBeUndefined();
|
expect((await middleware(missingDv)).cookies.get('tessera_desktop_client')).toBeUndefined();
|
||||||
|
|
||||||
const emptyCommit = new NextRequest('http://localhost:3000/login?desktop=1&dv=1.2.0&dc=&dos=linux');
|
const emptyCommit = new NextRequest(
|
||||||
expect((await middleware(emptyCommit)).cookies.get('tessera_desktop_client')?.value).toBe('1.2.0||linux');
|
'http://localhost:3000/login?desktop=1&dv=1.2.0&dc=&dos=linux',
|
||||||
|
);
|
||||||
|
expect((await middleware(emptyCommit)).cookies.get('tessera_desktop_client')?.value).toBe(
|
||||||
|
'1.2.0||linux',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Test 9 (quick-260918-gza, Redirect-Pfad): /dashboard ohne Session setzt beide Cookies auf dem 307', async () => {
|
it('Test 9 (quick-260918-gza, Redirect-Pfad): /dashboard ohne Session setzt beide Cookies auf dem 307', async () => {
|
||||||
const req = new NextRequest('http://localhost:3000/dashboard?desktop=1&dv=1.2.0&dc=a6d1a64&dos=linux');
|
const req = new NextRequest(
|
||||||
|
'http://localhost:3000/dashboard?desktop=1&dv=1.2.0&dc=a6d1a64&dos=linux',
|
||||||
|
);
|
||||||
const res = await middleware(req);
|
const res = await middleware(req);
|
||||||
expect(res.status).toBe(307);
|
expect(res.status).toBe(307);
|
||||||
expect(res.headers.get('location')).toContain('/login');
|
expect(res.headers.get('location')).toContain('/login');
|
||||||
@@ -114,3 +125,65 @@ describe('middleware — Desktop-Client-Cookie (260917-h2s)', () => {
|
|||||||
expect(res.cookies.get('tessera_desktop_client')?.value).toBe('1.2.0|a6d1a64|linux');
|
expect(res.cookies.get('tessera_desktop_client')?.value).toBe('1.2.0|a6d1a64|linux');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('middleware — /login bei bestehender Anmeldung (quick-260930)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubEnv('JWT_SECRET', 'test-secret');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function token(claims: Record<string, unknown> = {}) {
|
||||||
|
return new SignJWT({ sub: 'u1', ...claims })
|
||||||
|
.setProtectedHeader({ alg: 'HS256' })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime('5m')
|
||||||
|
.sign(new TextEncoder().encode('test-secret'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loginReq(url: string, session?: string) {
|
||||||
|
return new NextRequest(url, session ? { headers: { cookie: `session=${session}` } } : {});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('angemeldet: /login leitet aufs Dashboard um', async () => {
|
||||||
|
const res = await middleware(loginReq('http://localhost:3000/login', await token()));
|
||||||
|
expect(res.status).toBe(307);
|
||||||
|
expect(new URL(res.headers.get('location') as string).pathname).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('angemeldet mit sicherem next: dorthin', async () => {
|
||||||
|
const res = await middleware(
|
||||||
|
loginReq('http://localhost:3000/login?next=%2Fadmin%2Fusers', await token()),
|
||||||
|
);
|
||||||
|
expect(new URL(res.headers.get('location') as string).pathname).toBe('/admin/users');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('angemeldet mit fremdem oder zirkulaerem next: Dashboard', async () => {
|
||||||
|
for (const next of ['https%3A%2F%2Fboese.example', '%2F%2Fboese.example', '%2Flogin']) {
|
||||||
|
const res = await middleware(
|
||||||
|
loginReq(`http://localhost:3000/login?next=${next}`, await token()),
|
||||||
|
);
|
||||||
|
const loc = new URL(res.headers.get('location') as string);
|
||||||
|
expect(loc.host).toBe('localhost:3000');
|
||||||
|
expect(loc.pathname).toBe('/');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne oder mit ungueltigem Cookie: Anmeldeseite wie bisher', async () => {
|
||||||
|
const ohne = await middleware(loginReq('http://localhost:3000/login'));
|
||||||
|
expect(ohne.headers.get('x-middleware-next')).toBe('1');
|
||||||
|
const kaputt = await middleware(
|
||||||
|
loginReq('http://localhost:3000/login', 'kein.gueltiges.token'),
|
||||||
|
);
|
||||||
|
expect(kaputt.headers.get('x-middleware-next')).toBe('1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Kennwortwechsel ausstehend: keine Umleitung von /login', async () => {
|
||||||
|
const res = await middleware(
|
||||||
|
loginReq('http://localhost:3000/login', await token({ mustChangePassword: true })),
|
||||||
|
);
|
||||||
|
expect(res.headers.get('x-middleware-next')).toBe('1');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { type NextRequest, NextResponse } from 'next/server';
|
|
||||||
import { jwtVerify } from 'jose';
|
import { jwtVerify } from 'jose';
|
||||||
import { buildNextParam } from '@/lib/safe-next';
|
import { type NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { buildNextParam, sanitizeNextPath } from '@/lib/safe-next';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Next.js middleware for frontend route protection (Pattern 4).
|
* Next.js middleware for frontend route protection (Pattern 4).
|
||||||
@@ -113,6 +113,28 @@ function getSecret() {
|
|||||||
export async function middleware(req: NextRequest) {
|
export async function middleware(req: NextRequest) {
|
||||||
const path = req.nextUrl.pathname;
|
const path = req.nextUrl.pathname;
|
||||||
|
|
||||||
|
// Bereits angemeldet und /login aufgerufen (quick-260930, Wunsch des
|
||||||
|
// Nutzers): statt der Anmeldeseite direkt zum Ziel — `next`, sofern ein
|
||||||
|
// sicherer relativer Pfad, sonst das Dashboard. Nur bei gueltiger
|
||||||
|
// Signatur; ist das Konto inzwischen gesperrt, lehnt die API die Sitzung
|
||||||
|
// ab, die Oberflaeche loescht das Cookie serverseitig und schickt zur
|
||||||
|
// Anmeldung zurueck — dann ohne Cookie, also keine Schleife.
|
||||||
|
if (path === '/login' || path.startsWith('/login/')) {
|
||||||
|
const existing = req.cookies.get('session')?.value;
|
||||||
|
if (existing) {
|
||||||
|
try {
|
||||||
|
const { payload } = await jwtVerify(existing, getSecret(), { algorithms: ['HS256'] });
|
||||||
|
if (payload.mustChangePassword !== true) {
|
||||||
|
const next = sanitizeNextPath(req.nextUrl.searchParams.get('next'));
|
||||||
|
const target = next.startsWith('/login') ? '/' : next;
|
||||||
|
return withDesktopCookie(req, NextResponse.redirect(new URL(target, req.nextUrl)));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ungueltiges Cookie: Anmeldeseite wie gewohnt zeigen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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 withDesktopCookie(req, NextResponse.next());
|
return withDesktopCookie(req, NextResponse.next());
|
||||||
@@ -141,10 +163,7 @@ export async function middleware(req: NextRequest) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// D-06: Force password change redirect
|
// D-06: Force password change redirect
|
||||||
if (
|
if (payload.mustChangePassword === true && !path.startsWith('/change-password')) {
|
||||||
payload.mustChangePassword === true &&
|
|
||||||
!path.startsWith('/change-password')
|
|
||||||
) {
|
|
||||||
return withDesktopCookie(
|
return withDesktopCookie(
|
||||||
req,
|
req,
|
||||||
NextResponse.redirect(new URL('/change-password', req.nextUrl)),
|
NextResponse.redirect(new URL('/change-password', req.nextUrl)),
|
||||||
|
|||||||
Reference in New Issue
Block a user