diff --git a/web/src/account/auth.test.ts b/web/src/account/auth.test.ts index 967e103..4b5eac2 100644 --- a/web/src/account/auth.test.ts +++ b/web/src/account/auth.test.ts @@ -106,6 +106,82 @@ describe('login', () => { }); }); +describe('login bei bereits aktiver Sitzung (user_session_already_exists)', () => { + const existing = () => fail(401, 'user_session_already_exists', 'Creation of a session is prohibited when a session is active.'); + + it('löscht die aktuelle Sitzung, versucht es einmal neu und liefert den Nutzer', async () => { + const auth = await freshAuth(); + mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({}); + mocks.deleteSession.mockResolvedValue({}); + mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' }); + await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' }); + expect(mocks.deleteSession).toHaveBeenCalledTimes(1); + expect(mocks.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' }); + expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2); + expect(mocks.createEmailPasswordSession).toHaveBeenLastCalledWith({ email: 'a@b.de', password: 'geheim123' }); + expect(auth.currentUserId()).toBe('u1'); + }); + + it('ignoriert 401 beim Löschen (Sitzung war schon weg) und versucht es trotzdem neu', async () => { + const auth = await freshAuth(); + mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({}); + mocks.deleteSession.mockRejectedValue(fail(401, 'general_unauthorized_scope')); + mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' }); + await expect(auth.login('a@b.de', 'geheim123')).resolves.toEqual({ $id: 'u1', email: 'a@b.de' }); + expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2); + }); + + it('scheitert der zweite Versuch auch, fliegt dieser Fehler weiter (keine Endlosschleife)', async () => { + const auth = await freshAuth(); + mocks.createEmailPasswordSession.mockRejectedValue(existing()); + mocks.deleteSession.mockResolvedValue({}); + await expect(auth.login('a@b.de', 'geheim123')).rejects.toMatchObject({ type: 'user_session_already_exists' }); + expect(mocks.deleteSession).toHaveBeenCalledTimes(1); + expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(2); + expect(mocks.get).not.toHaveBeenCalled(); + }); + + it('falsche Zugangsdaten im zweiten Versuch kommen als solche an, der alte Nutzer ist dann abgemeldet', async () => { + const auth = await freshAuth(); + mocks.get.mockResolvedValueOnce({ $id: 'old', email: 'old@b.de' }); + await auth.getCurrentUser(); + const listener = vi.fn(); + auth.onAuthChange(listener); + const wrong = fail(401, 'user_invalid_credentials'); + mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockRejectedValueOnce(wrong); + mocks.deleteSession.mockResolvedValue({}); + await expect(auth.login('a@b.de', 'falsch123')).rejects.toBe(wrong); + expect(auth.currentUserId()).toBeNull(); + expect(listener).toHaveBeenCalledWith(null); + }); + + it('scheitert das Löschen anders (z. B. Netz), fliegt dieser Fehler weiter und es gibt keinen zweiten Versuch', async () => { + const auth = await freshAuth(); + const error = new TypeError('Failed to fetch'); + mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()); + mocks.deleteSession.mockRejectedValue(error); + await expect(auth.login('a@b.de', 'geheim123')).rejects.toBe(error); + expect(mocks.createEmailPasswordSession).toHaveBeenCalledTimes(1); + }); + + it('andere Fehler beim ersten Versuch löschen nichts', async () => { + const auth = await freshAuth(); + mocks.createEmailPasswordSession.mockRejectedValue(fail(401, 'user_invalid_credentials')); + await expect(auth.login('a@b.de', 'falsch123')).rejects.toMatchObject({ type: 'user_invalid_credentials' }); + expect(mocks.deleteSession).not.toHaveBeenCalled(); + }); + + it('register: das anschließende Login räumt eine vorhandene Sitzung ebenfalls ab', async () => { + const auth = await freshAuth(); + mocks.create.mockResolvedValue({}); + mocks.createEmailPasswordSession.mockRejectedValueOnce(existing()).mockResolvedValueOnce({}); + mocks.deleteSession.mockResolvedValue({}); + mocks.get.mockResolvedValue({ $id: 'u2', email: 'neu@b.de' }); + await expect(auth.register('neu@b.de', 'geheim123')).resolves.toEqual({ $id: 'u2', email: 'neu@b.de' }); + expect(mocks.deleteSession).toHaveBeenCalledTimes(1); + }); +}); + describe('register', () => { it('legt das Konto an (Name aus der E-Mail) und meldet danach an', async () => { const auth = await freshAuth(); diff --git a/web/src/account/auth.ts b/web/src/account/auth.ts index 187f41f..fa50ea2 100644 --- a/web/src/account/auth.ts +++ b/web/src/account/auth.ts @@ -2,7 +2,7 @@ // und meldet Änderungen an Abonnenten (Konto-Menü, später Karten-Seite). import { ID } from 'appwrite'; import { account } from './appwrite'; -import { isUnauthorized } from './errors'; +import { errorType, isUnauthorized } from './errors'; export type User = { $id: string; email: string }; @@ -52,8 +52,26 @@ export function currentUserId(): string | null { return cachedUser?.$id ?? null; } +/** Beendet die Sitzung dieses Browsers auf dem Server; 401 heißt, es gab keine mehr. */ +async function deleteCurrentSession(): Promise { + try { + await account.deleteSession({ sessionId: 'current' }); + } catch (err) { + if (!isUnauthorized(err)) throw err; + } +} + export async function login(email: string, password: string): Promise { - await account.createEmailPasswordSession({ email, password }); + try { + await account.createEmailPasswordSession({ email, password }); + } catch (err) { + if (errorType(err) !== 'user_session_already_exists') throw err; + // Der Browser schickt noch eine gültige Sitzung mit (anderer Tab, fehlgeschlagene Startprüfung, + // halb fertiger Login). Appwrite lehnt ein Login dann ab; also die alte Sitzung beenden und genau einmal neu versuchen. + await deleteCurrentSession(); + if (cachedUser) setUser(null); // die alte Sitzung ist weg, auch wenn der neue Versuch scheitert + await account.createEmailPasswordSession({ email, password }); + } const user = toUser(await account.get()); setUser(user); return user; @@ -70,12 +88,8 @@ export async function register(email: string, password: string): Promise { } export async function logout(): Promise { - try { - await account.deleteSession({ sessionId: 'current' }); - } catch (err) { - // 401: die Sitzung gab es schon nicht mehr – das Ziel „abgemeldet“ ist erreicht. - if (!isUnauthorized(err)) throw err; - } + // 401: die Sitzung gab es schon nicht mehr – das Ziel „abgemeldet“ ist erreicht. + await deleteCurrentSession(); setUser(null); } diff --git a/web/src/account/errors.test.ts b/web/src/account/errors.test.ts index 0ba90f5..cb954b9 100644 --- a/web/src/account/errors.test.ts +++ b/web/src/account/errors.test.ts @@ -56,6 +56,15 @@ describe('errorMessage nach type', () => { ); }); + it('user_session_already_exists: Hinweis mit Handlungsanweisung', () => { + expect(errorMessage(fail(401, 'user_session_already_exists'), 'login')).toBe( + 'Du bist schon angemeldet. Bitte lade die Seite neu.', + ); + expect(errorMessage(fail(401, 'user_session_already_exists'), 'register')).toBe( + 'Du bist schon angemeldet. Bitte lade die Seite neu.', + ); + }); + it('user_count_exceeded', () => { expect(errorMessage(fail(501, 'user_count_exceeded'), 'register')).toBe('Zurzeit sind keine neuen Konten möglich.'); }); diff --git a/web/src/account/errors.ts b/web/src/account/errors.ts index 54f5d6e..b7acfab 100644 --- a/web/src/account/errors.ts +++ b/web/src/account/errors.ts @@ -74,6 +74,8 @@ export function errorMessage(err: unknown, ctx: ErrorContext): string { return 'Das Bild dieser Karte fehlt.'; case 'general_unknown_origin': return 'Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.'; + case 'user_session_already_exists': + return 'Du bist schon angemeldet. Bitte lade die Seite neu.'; case 'user_count_exceeded': return 'Zurzeit sind keine neuen Konten möglich.'; } diff --git a/web/src/account/login-dialog.test.ts b/web/src/account/login-dialog.test.ts index fe050d2..0db2635 100644 --- a/web/src/account/login-dialog.test.ts +++ b/web/src/account/login-dialog.test.ts @@ -109,6 +109,20 @@ describe('requireLogin: Öffnen und Schließen', () => { }); }); +describe('requireLogin: showModal schlägt fehl', () => { + it('räumt auf und wirft; der nächste Aufruf öffnet normal', () => { + const spy = vi.spyOn(HTMLDialogElement.prototype, 'showModal').mockImplementationOnce(() => { + throw new Error('showModal kaputt'); + }); + expect(() => requireLogin()).toThrow('showModal kaputt'); + spy.mockRestore(); + const again = requireLogin(); + expect(dialogEl().open).toBe(true); + dialogEl().querySelector('.auth-close')!.click(); + return expect(again).resolves.toBe(false); + }); +}); + describe('Anmelden', () => { it('meldet an, löst mit true auf und schließt', async () => { mocks.createEmailPasswordSession.mockResolvedValue({}); @@ -150,6 +164,19 @@ describe('Anmelden', () => { await expect(result).resolves.toBe(true); }); + it('bereits aktive Sitzung: wird ersetzt, der Dialog meldet erfolgreich an', async () => { + mocks.createEmailPasswordSession + .mockRejectedValueOnce(fail(401, 'user_session_already_exists')) + .mockResolvedValueOnce({}); + mocks.deleteSession.mockResolvedValue({}); + mocks.get.mockResolvedValue({ $id: 'u1', email: 'a@b.de' }); + const result = requireLogin(); + fill({ email: 'a@b.de', password: 'geheim123' }); + submit(); + await expect(result).resolves.toBe(true); + expect(mocks.deleteSession).toHaveBeenCalledWith({ sessionId: 'current' }); + }); + it('Netzfehler: Meldung statt Absturz', async () => { mocks.createEmailPasswordSession.mockRejectedValue(new TypeError('Failed to fetch')); void requireLogin(); diff --git a/web/src/account/login-dialog.ts b/web/src/account/login-dialog.ts index b58827c..772e209 100644 --- a/web/src/account/login-dialog.ts +++ b/web/src/account/login-dialog.ts @@ -289,7 +289,13 @@ function createLoginDialog(): (view: LoginView) => Promise { settle = resolve; }); showView(initial); - dialog.showModal(); + try { + dialog.showModal(); + } catch (err) { + // Sonst bliebe `pending` für alle späteren Aufrufe ein totes Promise. + finish(false); + throw err; + } // showModal fokussiert das erste bedienbare Element (den Schließen-Knopf); wir wollen das erste Eingabefeld. content.querySelector('input')?.focus(); return pending;