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:
1 parent
10c55ca933
commit
a147d3b496
6 files changed
+143
-9
No files matched your search
@@ -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', () => {
|
describe('register', () => {
|
||||||
it('legt das Konto an (Name aus der E-Mail) und meldet danach an', async () => {
|
it('legt das Konto an (Name aus der E-Mail) und meldet danach an', async () => {
|
||||||
const auth = await freshAuth();
|
const auth = await freshAuth();
|
||||||
|
|||||||
+22
-8
@@ -2,7 +2,7 @@
|
|||||||
// und meldet Änderungen an Abonnenten (Konto-Menü, später Karten-Seite).
|
// und meldet Änderungen an Abonnenten (Konto-Menü, später Karten-Seite).
|
||||||
import { ID } from 'appwrite';
|
import { ID } from 'appwrite';
|
||||||
import { account } from './appwrite';
|
import { account } from './appwrite';
|
||||||
import { isUnauthorized } from './errors';
|
import { errorType, isUnauthorized } from './errors';
|
||||||
|
|
||||||
export type User = { $id: string; email: string };
|
export type User = { $id: string; email: string };
|
||||||
|
|
||||||
@@ -52,8 +52,26 @@ export function currentUserId(): string | null {
|
|||||||
return cachedUser?.$id ?? 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> {
|
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());
|
const user = toUser(await account.get());
|
||||||
setUser(user);
|
setUser(user);
|
||||||
return user;
|
return user;
|
||||||
@@ -70,12 +88,8 @@ export async function register(email: string, password: string): Promise<User> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
try {
|
// 401: die Sitzung gab es schon nicht mehr – das Ziel „abgemeldet“ ist erreicht.
|
||||||
await account.deleteSession({ sessionId: 'current' });
|
await deleteCurrentSession();
|
||||||
} catch (err) {
|
|
||||||
// 401: die Sitzung gab es schon nicht mehr – das Ziel „abgemeldet“ ist erreicht.
|
|
||||||
if (!isUnauthorized(err)) throw err;
|
|
||||||
}
|
|
||||||
setUser(null);
|
setUser(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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', () => {
|
it('user_count_exceeded', () => {
|
||||||
expect(errorMessage(fail(501, 'user_count_exceeded'), 'register')).toBe('Zurzeit sind keine neuen Konten möglich.');
|
expect(errorMessage(fail(501, 'user_count_exceeded'), 'register')).toBe('Zurzeit sind keine neuen Konten möglich.');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ export function errorMessage(err: unknown, ctx: ErrorContext): string {
|
|||||||
return 'Das Bild dieser Karte fehlt.';
|
return 'Das Bild dieser Karte fehlt.';
|
||||||
case 'general_unknown_origin':
|
case 'general_unknown_origin':
|
||||||
return 'Diese Seite ist beim Server nicht freigeschaltet. Bitte melde dich beim Betreiber.';
|
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':
|
case 'user_count_exceeded':
|
||||||
return 'Zurzeit sind keine neuen Konten möglich.';
|
return 'Zurzeit sind keine neuen Konten möglich.';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
describe('Anmelden', () => {
|
||||||
it('meldet an, löst mit true auf und schließt', async () => {
|
it('meldet an, löst mit true auf und schließt', async () => {
|
||||||
mocks.createEmailPasswordSession.mockResolvedValue({});
|
mocks.createEmailPasswordSession.mockResolvedValue({});
|
||||||
@@ -150,6 +164,19 @@ describe('Anmelden', () => {
|
|||||||
await expect(result).resolves.toBe(true);
|
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 () => {
|
it('Netzfehler: Meldung statt Absturz', async () => {
|
||||||
mocks.createEmailPasswordSession.mockRejectedValue(new TypeError('Failed to fetch'));
|
mocks.createEmailPasswordSession.mockRejectedValue(new TypeError('Failed to fetch'));
|
||||||
void requireLogin();
|
void requireLogin();
|
||||||
|
|||||||
@@ -289,7 +289,13 @@ function createLoginDialog(): (view: LoginView) => Promise<boolean> {
|
|||||||
settle = resolve;
|
settle = resolve;
|
||||||
});
|
});
|
||||||
showView(initial);
|
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.
|
// showModal fokussiert das erste bedienbare Element (den Schließen-Knopf); wir wollen das erste Eingabefeld.
|
||||||
content.querySelector<HTMLElement>('input')?.focus();
|
content.querySelector<HTMLElement>('input')?.focus();
|
||||||
return pending;
|
return pending;
|
||||||
|
|||||||
Reference in new issue
Block a user