From b3c6e658012587a8db7b2fc74edffd6bf0f217a4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 01:13:59 +0000 Subject: [PATCH] =?UTF-8?q?fix(passwort):=20Erfolgsansicht=20=C3=BCberlebt?= =?UTF-8?q?=20den=20Neuaufbau=20der=20Seite=20beim=20Sprachwechsel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Schlussreview F3. showDone() entfernt userId und secret aus der Adresse. Wechselte der Nutzer danach die Sprache, baute main.ts die Seite neu auf und sie las eine Adresse ohne Link: „Der Link ist ungültig oder abgelaufen.“ statt des Erfolgs. - Ein Stand außerhalb der Seite (`passwordChanged`, Modulvariable) merkt, dass das Passwort gesetzt wurde; ein Neuaufbau zeigt dann wieder die Erfolgsansicht (in der neuen Sprache) mit „Anmelden“. - Ein frischer Link (userId und secret in der Adresse) setzt den Stand zurück und zeigt das Formular. Gemerkt wird nur der Erfolg, ein Fehlschlag nicht. - Tests: Neuaufbau nach dem Erfolg (deutsch, mehrfach, englisch), frischer Link danach, Seite ohne Link bleibt ungültig, Fehlschlag wird nicht als Erfolg gemerkt. Co-Authored-By: Claude Opus 5.5 --- web/src/pages/reset-page.test.ts | 82 ++++++++++++++++++++++++++++++++ web/src/pages/reset-page.ts | 12 ++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/web/src/pages/reset-page.test.ts b/web/src/pages/reset-page.test.ts index 8520429..2748436 100644 --- a/web/src/pages/reset-page.test.ts +++ b/web/src/pages/reset-page.test.ts @@ -151,3 +151,85 @@ describe('mit Link', () => { expect(root.querySelector('form')).not.toBeNull(); }); }); + +describe('Neuaufbau der Seite (Sprachwechsel) nach dem Erfolg', () => { + const SUCCESS = 'Passwort geändert. Du kannst dich jetzt anmelden.'; + + /** Setzt das Passwort mit dem Link und wartet auf die Erfolgsansicht; gibt die Aufräum-Funktion der Seite zurück. */ + async function succeed(): Promise<() => void> { + withLink(); + mocks.updateRecovery.mockResolvedValue({}); + const unmount = mountResetPage(root); + fill('neuesPasswort1'); + submit(); + await vi.waitFor(() => expect(root.querySelector('[role="status"]')!.textContent).toBe(SUCCESS)); + expect(location.search).toBe(''); // das Geheimnis ist aus der Adresse + return unmount; + } + + it('ein Neuaufbau zeigt wieder den Erfolg und „Anmelden“, nicht „Der Link ist ungültig oder abgelaufen.“', async () => { + const unmount = await succeed(); + unmount(); // main.ts: der Router räumt die Seite auf und baut sie mit derselben Adresse neu auf + mountResetPage(root); + expect(root.querySelector('[role="status"]')!.textContent).toBe(SUCCESS); + expect(root.querySelector('[role="alert"]')).toBeNull(); + expect(root.querySelector('form')).toBeNull(); + expect(root.querySelector('h1')!.textContent).toBe('Passwort neu setzen'); + expect(location.search).toBe(''); + button('Anmelden').click(); + expect(document.querySelector('dialog.auth-dialog')!.open).toBe(true); + expect(mocks.updateRecovery).toHaveBeenCalledTimes(1); + }); + + it('wiederholt sich beliebig oft (mehrere Sprachwechsel hintereinander)', async () => { + let unmount = await succeed(); + for (let i = 0; i < 3; i++) { + unmount(); + unmount = mountResetPage(root); + expect(root.querySelector('[role="status"]')!.textContent).toBe(SUCCESS); + } + }); + + it('Sprachwechsel auf Englisch: der Neuaufbau zeigt den Erfolg übersetzt', async () => { + const unmount = await succeed(); + const { setLang } = await import('../i18n/i18n'); // dieselbe Kopie des Moduls wie die der Seite (siehe vi.resetModules oben) + setLang('en', { persist: false }); + unmount(); + mountResetPage(root); + expect(root.querySelector('[role="status"]')!.textContent).toBe('Password changed. You can now sign in.'); + expect(root.querySelector('[role="alert"]')).toBeNull(); + expect(button('Sign in')).toBeDefined(); + }); + + it('ein frischer Link (userId und secret in der Adresse) zeigt wieder das Formular', async () => { + const unmount = await succeed(); + unmount(); + withLink(); + mountResetPage(root); + expect(root.querySelector('form')).not.toBeNull(); + expect(root.querySelector('[role="status"]')).toBeNull(); + }); + + it('ohne vorherigen Erfolg bleibt die Seite ohne Link ungültig, auch nach einem Neuaufbau', () => { + const unmount = mountResetPage(root); + expect(alertText()).toBe('Der Link ist ungültig oder abgelaufen.'); + unmount(); + mountResetPage(root); + expect(alertText()).toBe('Der Link ist ungültig oder abgelaufen.'); + expect(root.querySelector('[role="status"]')).toBeNull(); + }); + + it('nur ein Erfolg wird gemerkt: nach einem Fehlschlag (ungültiger Link) zeigt die Seite ohne Link weiter „Link ungültig“', async () => { + withLink(); + mocks.updateRecovery.mockRejectedValue(fail(401, 'user_invalid_token')); + const unmount = mountResetPage(root); + fill('neuesPasswort1'); + submit(); + await vi.waitFor(() => expect(root.querySelector('form')).toBeNull()); + unmount(); + history.replaceState(null, '', '/passwort-neu'); + mountResetPage(root); + expect(alertText()).toBe('Der Link ist ungültig oder abgelaufen.'); + expect(root.querySelector('[role="status"]')).toBeNull(); + }); +}); diff --git a/web/src/pages/reset-page.ts b/web/src/pages/reset-page.ts index 1bfb3ab..7bf4250 100644 --- a/web/src/pages/reset-page.ts +++ b/web/src/pages/reset-page.ts @@ -9,11 +9,19 @@ import { navigate } from './router'; const MISMATCH = msg('Die Passwörter stimmen nicht überein.'); +/** + * Das Passwort wurde mit dem Link schon gesetzt. Der Stand lebt außerhalb der Seite: showDone() entfernt Kennung und Geheimnis aus der + * Adresse, und baut sich die Seite danach neu auf (Sprachwechsel, siehe main.ts), zeigte sie sonst „Der Link ist ungültig oder + * abgelaufen.“. Ein frischer Link (userId und secret in der Adresse) setzt den Stand zurück. + */ +let passwordChanged = false; + export function mountResetPage(root: HTMLElement): () => void { let disposed = false; const params = new URLSearchParams(location.search); const userId = params.get('userId') ?? ''; const secret = params.get('secret') ?? ''; + if (userId && secret) passwordChanged = false; const heading = el('h1', {}, t('Passwort neu setzen')); const page = el('section', { class: 'reset-page' }); @@ -29,6 +37,7 @@ export function mountResetPage(root: HTMLElement): () => void { } function showDone(): void { + passwordChanged = true; // Das Geheimnis ist verbraucht; es soll nicht in der Adresszeile und im Verlauf stehen bleiben. history.replaceState(null, '', location.pathname); const signIn = el('button', { type: 'button', class: 'auth-btn auth-btn-primary' }, t('Anmelden')); @@ -122,7 +131,8 @@ export function mountResetPage(root: HTMLElement): () => void { page.replaceChildren(form); } - if (userId && secret) showForm(); + if (passwordChanged) showDone(); + else if (userId && secret) showForm(); else showInvalidLink(); return () => {