fix(cards): Editor an Karte und Konto koppeln (Neue Karte, Abmelden, Kontowechsel)
- Editor zeigt „Bearbeitet: <Name>“, solange eine gespeicherte Karte gebunden ist, und bietet „Neue Karte“ (setzt zurück, bricht Laden ab, entfernt ?karte=; fragt bei ungespeicherten Änderungen per eigenem Dialog „Verwerfen?“) - EditorState.savedConfig als Vergleichsbasis für ungespeicherte Änderungen - bindEditorToAuth: Abmelden leert den Editor, Kontowechsel A -> B entkoppelt die Karte (Entwurf bleibt), Anmelden ohne Vorgänger und abgelaufene Sitzung behalten den Entwurf; auth.onLogout unterscheidet eigenes Abmelden von bemerktem Ablauf - Speichern nimmt den Schnappschuss pro Klick (auch für die Wiederholung nach 401) - save: row_not_found beim Update legt eine neue Karte an (inkl. Aufräumen) - Karten-Seite: Editor-Abgleich auch nach dem Verlassen der Seite, Löschen-Dialog wird beim Verlassen geschlossen, keine doppelten Vorschau-URLs - confirm-dialog als gemeinsames Modul (Löschen, Verwerfen) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ba4d9f76d4
commit
38b361ad7a
20 files changed
+1038
-116
No files matched your search
@@ -33,6 +33,16 @@
|
||||
background: var(--primary-hover);
|
||||
}
|
||||
|
||||
.auth-btn-danger {
|
||||
border-color: var(--danger);
|
||||
color: #ff9a9d;
|
||||
}
|
||||
|
||||
.auth-btn-danger:hover:not(:disabled) {
|
||||
border-color: var(--danger);
|
||||
background: rgb(229 72 77 / 0.16);
|
||||
}
|
||||
|
||||
.auth-btn-small {
|
||||
min-height: 38px;
|
||||
padding: 0 16px;
|
||||
|
||||
@@ -251,6 +251,60 @@ describe('logout', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('onLogout', () => {
|
||||
it('wird nach dem eigenen Abmelden aufgerufen, auch wenn die Sitzung schon weg war (401)', async () => {
|
||||
const auth = await freshAuth();
|
||||
const fn = vi.fn();
|
||||
auth.onLogout(fn);
|
||||
mocks.deleteSession.mockResolvedValueOnce({});
|
||||
await auth.logout();
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
mocks.deleteSession.mockRejectedValueOnce(fail(401, 'general_unauthorized_scope'));
|
||||
await auth.logout();
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('wird nicht aufgerufen, wenn nur eine abgelaufene Sitzung bemerkt wird (getCurrentUser → 401)', async () => {
|
||||
const auth = await freshAuth();
|
||||
mocks.get.mockResolvedValueOnce({ $id: 'u1', email: 'a@b.de' });
|
||||
await auth.getCurrentUser();
|
||||
const fn = vi.fn();
|
||||
const change = vi.fn();
|
||||
auth.onLogout(fn);
|
||||
auth.onAuthChange(change);
|
||||
mocks.get.mockRejectedValueOnce(fail(401, 'general_unauthorized_scope'));
|
||||
await auth.getCurrentUser();
|
||||
expect(change).toHaveBeenCalledWith(null);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wird nicht aufgerufen, wenn das Abmelden scheitert, und auch nicht beim Login mit alter Sitzung', async () => {
|
||||
const auth = await freshAuth();
|
||||
const fn = vi.fn();
|
||||
auth.onLogout(fn);
|
||||
mocks.deleteSession.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||
await expect(auth.logout()).rejects.toBeInstanceOf(TypeError);
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
|
||||
mocks.createEmailPasswordSession
|
||||
.mockRejectedValueOnce(fail(409, 'user_session_already_exists'))
|
||||
.mockResolvedValueOnce({});
|
||||
mocks.deleteSession.mockResolvedValueOnce({});
|
||||
mocks.get.mockResolvedValue({ $id: 'u2', email: 'b@b.de' });
|
||||
await auth.login('b@b.de', 'geheim123');
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('gibt eine Abmelde-Funktion zurück', async () => {
|
||||
const auth = await freshAuth();
|
||||
const fn = vi.fn();
|
||||
auth.onLogout(fn)();
|
||||
mocks.deleteSession.mockResolvedValue({});
|
||||
await auth.logout();
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('onAuthChange', () => {
|
||||
it('gibt eine Abmelde-Funktion zurück', async () => {
|
||||
const auth = await freshAuth();
|
||||
|
||||
@@ -11,6 +11,7 @@ const PASSWORD_MAX = 256;
|
||||
|
||||
let cachedUser: User | null = null;
|
||||
const listeners = new Set<(user: User | null) => void>();
|
||||
const logoutListeners = new Set<() => void>();
|
||||
|
||||
function notify(): void {
|
||||
// Kopie, damit sich ein Abonnent beim Verteilen abmelden darf.
|
||||
@@ -91,6 +92,7 @@ export async function logout(): Promise<void> {
|
||||
// 401: die Sitzung gab es schon nicht mehr – das Ziel „abgemeldet“ ist erreicht.
|
||||
await deleteCurrentSession();
|
||||
setUser(null);
|
||||
for (const fn of [...logoutListeners]) fn();
|
||||
}
|
||||
|
||||
export async function requestPasswordReset(email: string): Promise<void> {
|
||||
@@ -109,6 +111,17 @@ export function onAuthChange(fn: (user: User | null) => void): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registriert eine Funktion, die nur aufgerufen wird, wenn der Nutzer sich selbst abgemeldet hat – nicht,
|
||||
* wenn eine abgelaufene Sitzung bemerkt wird (dann meldet nur onAuthChange `null`). Gibt die Abmelde-Funktion zurück.
|
||||
*/
|
||||
export function onLogout(fn: () => void): () => void {
|
||||
logoutListeners.add(fn);
|
||||
return () => {
|
||||
logoutListeners.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
/** null, wenn das Passwort passt; sonst die Meldung für den Nutzer. */
|
||||
export function validatePassword(pw: string): string | null {
|
||||
return pw.length >= PASSWORD_MIN && pw.length <= PASSWORD_MAX
|
||||
|
||||
@@ -365,6 +365,58 @@ describe('save: bestehende Karte', () => {
|
||||
expect(saved.imageFileId).toBe('file1');
|
||||
});
|
||||
|
||||
describe('die Zeile gibt es nicht mehr (row_not_found beim Update)', () => {
|
||||
const gone = () => new AppwriteException('Row not found', 404, 'row_not_found');
|
||||
|
||||
it('mit Bildwechsel: legt eine neue Karte mit den eben hochgeladenen Dateien an, räumt die alten ab', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
fake.failOnce.set('updateRow', gone());
|
||||
const saved = await service(fake).save(newInput({ name: 'Neu', imageChanged: true, previous: previous() }));
|
||||
|
||||
expect(fake.names()).toEqual(['createFile', 'createFile', 'updateRow', 'createRow', 'deleteFile', 'deleteFile']);
|
||||
expect(fake.methods('createRow')[0].args).toEqual([
|
||||
{ name: 'Neu', config: serializeConfig(config()), imageFileId: 'file1', previewFileId: 'file2' },
|
||||
OWNER,
|
||||
]);
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['oldImage', 'oldPreview']);
|
||||
expect(saved.id).toBe('row-new');
|
||||
expect(saved.imageFileId).toBe('file1');
|
||||
expect(saved.previewFileId).toBe('file2');
|
||||
});
|
||||
|
||||
it('ohne Bildwechsel: das Bild wird aus dem Editor neu hochgeladen (die alte Datei gehörte der verschwundenen Karte)', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
fake.failOnce.set('updateRow', gone());
|
||||
const saved = await service(fake).save(newInput({ imageChanged: false, previous: previous() }));
|
||||
|
||||
const files = fake.methods('createFile');
|
||||
expect(files).toHaveLength(2);
|
||||
expect((files[0].args[0] as File).name).toBe('preview.webp');
|
||||
expect((files[1].args[0] as File).name).toBe('image.png');
|
||||
expect(files[1].args[1]).toEqual(OWNER);
|
||||
expect(fake.methods('createRow')[0].args[0]).toMatchObject({ imageFileId: 'file2', previewFileId: 'file1' });
|
||||
expect(saved).toMatchObject({ id: 'row-new', imageFileId: 'file2', previewFileId: 'file1' });
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['oldImage', 'oldPreview']);
|
||||
});
|
||||
|
||||
it('scheitert auch das Anlegen, werden alle neuen Dateien gelöscht, die alten bleiben, der Fehler kommt an', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
const boom = new Error('createRow');
|
||||
fake.failOnce.set('updateRow', gone());
|
||||
fake.failOnce.set('createRow', boom);
|
||||
await expect(service(fake).save(newInput({ imageChanged: false, previous: previous() }))).rejects.toBe(boom);
|
||||
expect(fake.methods('deleteFile').map((c) => c.args[0]).sort()).toEqual(['file1', 'file2']);
|
||||
});
|
||||
|
||||
it('andere Update-Fehler legen keine neue Karte an', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
const boom = new AppwriteException('Server', 500, 'general_server_error');
|
||||
fake.failOnce.set('updateRow', boom);
|
||||
await expect(service(fake).save(newInput({ previous: previous() }))).rejects.toBe(boom);
|
||||
expect(fake.methods('createRow')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('wirft userId(), wird nichts hochgeladen und nichts geändert', async () => {
|
||||
const fake = fakeApi([existing()]);
|
||||
const svc = service(fake, () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { parseConfig, serializeConfig, type CardConfig } from '../card/config';
|
||||
import type { SavedCardRef } from '../editor/state';
|
||||
import { APPWRITE_ENDPOINT, IDS, client, storage, tables } from './appwrite';
|
||||
import { currentUserId } from './auth';
|
||||
import { errorType } from './errors';
|
||||
|
||||
export interface CardRowData {
|
||||
name: string;
|
||||
@@ -106,11 +107,25 @@ export function createCardsService(api: CardsApi, userId: () => string) {
|
||||
};
|
||||
|
||||
let row: CardRow;
|
||||
// Die Zeile gab es nicht mehr (anderer Tab, anderes Konto): dann wurde eine neue Karte angelegt, die alten Dateien sind Müll.
|
||||
let replacedMissingCard = false;
|
||||
try {
|
||||
const imageFileId = imageFile ? await upload(imageFile) : (prev as SavedCardRef).imageFileId;
|
||||
const previewFileId = await upload(previewFile);
|
||||
const data: CardRowData = { name, config, imageFileId, previewFileId };
|
||||
row = prev ? await api.updateRow(prev.id, data) : await api.createRow(data, permissions);
|
||||
if (!prev) {
|
||||
row = await api.createRow(data, permissions);
|
||||
} else {
|
||||
try {
|
||||
row = await api.updateRow(prev.id, data);
|
||||
} catch (err) {
|
||||
if (errorType(err) !== 'row_not_found') throw err;
|
||||
replacedMissingCard = true;
|
||||
// Ohne Bildwechsel zeigt `data` auf die Datei der verschwundenen Karte; die neue Karte bekommt ein eigenes Bild.
|
||||
const ownImageId = imageFile ? imageFileId : await upload(toUploadFile(input.image, 'image'));
|
||||
row = await api.createRow({ ...data, imageFileId: ownImageId }, permissions);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
await deleteQuietly(api, uploaded);
|
||||
throw err;
|
||||
@@ -118,7 +133,8 @@ export function createCardsService(api: CardsApi, userId: () => string) {
|
||||
|
||||
if (prev) {
|
||||
// Die Karte zeigt schon auf die neuen Dateien; die alten sind nur noch Müll.
|
||||
await deleteQuietly(api, [prev.previewFileId, ...(uploadImage ? [prev.imageFileId] : [])]);
|
||||
const oldFiles = replacedMissingCard || uploadImage ? [prev.previewFileId, prev.imageFileId] : [prev.previewFileId];
|
||||
await deleteQuietly(api, oldFiles);
|
||||
}
|
||||
return toSaved(row);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user