fix(account): Login bei bereits aktiver Sitzung, showModal-Fehler abfangen

- login(): bei user_session_already_exists aktuelle Sitzung beenden und genau
  einmal neu versuchen; Meldung für den Restfall in errorMessage
- requireLogin(): schlägt showModal fehl, wird aufgeräumt und der Fehler geworfen,
  damit spätere Aufrufe nicht an einem toten Promise hängen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-04 09:02:42 +00:00
1 parent 10c55ca933
commit a147d3b496
6 files changed
+143 -9

No files matched your search

+76
View File
@@ -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();
+22 -8
View File
@@ -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<void> {
try {
await account.deleteSession({ sessionId: 'current' });
} catch (err) {
if (!isUnauthorized(err)) throw err;
}
}
export async function login(email: string, password: string): Promise<User> {
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<User> {
}
export async function logout(): Promise<void> {
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);
}
+9
View File
@@ -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.');
});
+2
View File
@@ -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.';
}
+27
View File
@@ -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<HTMLButtonElement>('.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();
+7 -1
View File
@@ -289,7 +289,13 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
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<HTMLElement>('input')?.focus();
return pending;