fix(community): gemeinsames formatDate, Rechte beim Ausführen, Melden-Dialog abgesichert

formatDate liegt jetzt in pages/format.ts und wird von Meine Karten und der
Detailseite genutzt; LOGIN_NEEDED kommt aus account/with-login.ts. Ausblenden,
Löschen und Zurückziehen prüfen Admin- bzw. Autor-Recht beim Ausführen (auch nach
der Rückfrage). Ein werfendes showModal im Melden-Dialog zeigt einen Hinweis statt
eines unbehandelten Fehlers.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 15:35:44 +00:00
1 parent 3cec7151a6
commit be440bc8e9
7 files changed
+135 -40

No files matched your search

+3
View File
@@ -3,6 +3,9 @@ import { getCurrentUser } from './auth';
import { errorType, isUnauthorized } from './errors';
import { requireLogin } from './login-dialog';
/** Hinweis, wenn eine Aktion an einer abgebrochenen Anmeldung scheitert. */
export const LOGIN_NEEDED = 'Bitte melde dich an, um fortzufahren.';
/** Der Nutzer hat den Login-Dialog geschlossen, ohne sich anzumelden. */
export class LoginCancelled extends Error {
constructor() {
+1 -12
View File
@@ -52,7 +52,7 @@ vi.mock('../account/cards', async (importOriginal) => ({
}),
}));
import { formatDate, mountCardsPage } from './cards-page';
import { mountCardsPage } from './cards-page';
const user: User = { $id: 'u1', email: 'a@b.de' };
@@ -105,17 +105,6 @@ const button = (scope: ParentNode, label: string) =>
const dialog = () => document.querySelector<HTMLDialogElement>('dialog.confirm-dialog');
const submit = (form: HTMLFormElement) => form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
describe('formatDate', () => {
it('schreibt dd.mm.yyyy', () => {
expect(formatDate(new Date(2026, 9, 4, 12).toISOString())).toBe('04.10.2026');
expect(formatDate(new Date(2026, 0, 31, 12).toISOString())).toBe('31.01.2026');
});
it('gibt bei unlesbarem Datum nichts zurück', () => {
expect(formatDate('kaputt')).toBe('');
});
});
describe('Meine Karten: Zustände', () => {
it('abgemeldet: Hinweis und Anmelden-Knopf, keine Anfrage an den Server', async () => {
mocks.getCurrentUser.mockResolvedValue(null);
+2 -10
View File
@@ -5,7 +5,7 @@ import { getCurrentUser, onAuthChange } from '../account/auth';
import { cardsService, normalizeName, type SavedCard } from '../account/cards';
import { errorMessage, errorType } from '../account/errors';
import { requireLogin } from '../account/login-dialog';
import { LoginCancelled, withLogin } from '../account/with-login';
import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login';
import { clampCrop } from '../card/crop';
import { CARD_W } from '../card/geometry';
import { exportCard, loadCardAssets } from '../card/render';
@@ -14,22 +14,14 @@ import { el } from '../editor/dom';
import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download';
import type { EditorStore } from '../editor/state';
import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage } from '../editor/upload';
import { formatDate } from './format';
import { navigate } from './router';
const LOADING = 'Karten werden geladen …';
const LOGGED_OUT = 'Melde dich an, um deine Karten zu sehen.';
const EMPTY = 'Noch keine Karten gespeichert.';
const LOGIN_NEEDED = 'Bitte melde dich an, um fortzufahren.';
const PREVIEW_PARALLEL = 4;
/** `dd.mm.yyyy` in der Zeitzone des Betrachters; leer, wenn das Datum unlesbar ist. */
export function formatDate(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`;
}
export function mountCardsPage(root: HTMLElement, store: EditorStore): () => void {
const service = cardsService();
let disposed = false;
+84
View File
@@ -325,6 +325,74 @@ describe('Detailseite: Karte', () => {
});
});
describe('Detailseite: Rechte beim Ausführen', () => {
it('Admin-Recht verloren, Knopf noch im DOM: Ausblenden sendet nichts', async () => {
user = admin;
await mount();
const hide = buttonByText('Ausblenden')!;
user = stranger; // ohne auth-Ereignis: der alte Knopf steht noch da
hide.click();
await settle();
expect(setVisibility).not.toHaveBeenCalled();
expect(withLogin).not.toHaveBeenCalled();
});
it('Admin-Recht verloren, Knopf noch im DOM: Löschen fragt nicht einmal nach', async () => {
user = admin;
await mount();
const del = buttonByText('Löschen')!;
user = stranger;
del.click();
await settle();
expect(confirmEl()).toBeNull();
expect(withdrawCard).not.toHaveBeenCalled();
});
it('Autor-Prüfung beim Ausführen: ein anderer Nutzer kann nicht zurückziehen', async () => {
user = owner;
await mount();
const withdraw = buttonByText('Zurückziehen')!;
user = stranger;
withdraw.click();
await settle();
expect(confirmEl()).toBeNull();
expect(withdrawCard).not.toHaveBeenCalled();
});
it('Recht geht während der Rückfrage verloren: nach „Löschen“ im Dialog wird nichts gesendet', async () => {
user = admin;
await mount();
buttonByText('Löschen')!.click();
await settle();
expect(confirmEl()?.open).toBe(true);
login(stranger);
confirmButton('Löschen').click();
await settle();
expect(withdrawCard).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
expect(errorText()).toBe('');
});
it('Recht geht während der Rückfrage verloren: auch Zurückziehen sendet nichts', async () => {
user = owner;
await mount();
buttonByText('Zurückziehen')!.click();
await settle();
login(null);
confirmButton('Zurückziehen').click();
await settle();
expect(withdrawCard).not.toHaveBeenCalled();
expect(navigate).not.toHaveBeenCalled();
});
it('ein Admin, der nicht Autor ist, kann nicht „zurückziehen“, aber löschen', async () => {
user = admin;
await mount();
expect(buttonTexts()).not.toContain('Zurückziehen');
expect(buttonTexts()).toContain('Löschen');
});
});
describe('Detailseite: Aufräumen', () => {
it('beim Verlassen wird der Auth-Abonnent abgemeldet, danach ändert sich nichts mehr', async () => {
await mount();
@@ -435,6 +503,22 @@ describe('Detailseite: Melden', () => {
expect(reportEl()).toBeNull();
});
it('lässt sich der Dialog nicht darstellen (showModal wirft), erscheint ein Hinweis statt eines unbehandelten Fehlers', async () => {
const spy = vi.spyOn(HTMLDialogElement.prototype, 'showModal').mockImplementation(() => {
throw new Error('nicht darstellbar');
});
try {
await mount();
buttonByText('Melden')!.click();
await settle();
expect(errorText()).toBe('Der Melden-Dialog konnte nicht geöffnet werden.');
expect(reportEl()).toBeNull();
expect(buttonByText('Melden')!.disabled).toBe(false);
} finally {
spy.mockRestore();
}
});
it('eine ausgeblendete Karte (nur Autor/Admin sehen sie) hat keinen Melden-Knopf', async () => {
user = owner;
getCard.mockResolvedValue(card({ hidden: true }));
+23 -18
View File
@@ -5,7 +5,7 @@
import '../community/community.css';
import { currentUser, getCurrentUser, isAdmin, onAuthChange, type User } from '../account/auth';
import { errorMessage } from '../account/errors';
import { LoginCancelled, withLogin } from '../account/with-login';
import { LOGIN_NEEDED, LoginCancelled, withLogin } from '../account/with-login';
import { ApiError, reportCard, setVisibility, withdrawCard, type ReportReason } from '../community/api';
import {
getGalleryCard,
@@ -18,6 +18,7 @@ import { REPORT_REASONS, reportDialog } from '../community/report-dialog';
import { confirmDialog, type ConfirmOptions } from '../editor/confirm-dialog';
import { el } from '../editor/dom';
import { downloadFileName, saveBlobAs } from '../editor/download';
import { formatDate } from './format';
import { navigate, titleFor } from './router';
/** Alles, was die Seite von außen braucht (für Tests austauschbar). */
@@ -62,7 +63,7 @@ const LOAD_ERROR = 'Die Karte konnte gerade nicht geladen werden.';
const IMAGE_LOADING = 'Bild wird geladen …';
const IMAGE_ERROR = 'Das Bild konnte nicht geladen werden.';
const REASONS_ERROR = 'Die Gründe konnten nicht geladen werden.';
const LOGIN_NEEDED = 'Bitte melde dich an, um fortzufahren.';
const REPORT_ERROR = 'Der Melden-Dialog konnte nicht geöffnet werden.';
const HIDDEN_NOTICE = 'Diese Karte ist für andere nicht sichtbar.';
const REASON_LABELS: ReadonlyMap<string, string> = new Map(REPORT_REASONS.map((r) => [r.value, r.label]));
@@ -73,14 +74,6 @@ interface ReportsState {
list: GalleryReport[];
}
/** `dd.mm.yyyy` in der Zeitzone des Besuchers; leer bei einem unlesbaren Datum. */
function formatDate(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`;
}
/** „1 Meldung“, „3 Meldungen“. */
function reportsLabel(count: number): string {
return count === 1 ? '1 Meldung' : `${count} Meldungen`;
@@ -376,11 +369,16 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
async function reportThis(): Promise<void> {
const c = card;
if (!c || busy) return;
await reportDialog({
cardTitle: c.title,
signal: disposal.signal,
send: (reason) => deps.reportCard(c.id, reason),
});
try {
await reportDialog({
cardTitle: c.title,
signal: disposal.signal,
send: (reason) => deps.reportCard(c.id, reason),
});
} catch {
// Dialog nicht darstellbar (showModal wirft): kein unbehandelter Fehler, sondern ein Hinweis auf der Seite.
if (!disposed) showMessage(REPORT_ERROR, 'error');
}
}
async function ask(options: ConfirmOptions): Promise<boolean> {
@@ -391,10 +389,16 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
}
}
/** Zurückziehen darf der Autor, Löschen ein Admin; geprüft wird beim Ausführen mit dem aktuellen Nutzer. */
function mayRemove(kind: 'withdraw' | 'delete', c: GalleryCard): boolean {
const user = deps.currentUser();
return kind === 'delete' ? isAdmin(user) : isOwner(c, user);
}
/** Zurückziehen (Autor) und Löschen (Admin) sind für den Dienst dasselbe: Zeile, Dateien und Meldungen verschwinden. */
async function removeCard(kind: 'withdraw' | 'delete'): Promise<void> {
const c = card;
if (!c || busy) return;
if (!c || busy || !mayRemove(kind, c)) return;
const mine = generation;
const confirmed = await ask(
kind === 'withdraw'
@@ -409,7 +413,8 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
confirmLabel: 'Löschen',
},
);
if (!confirmed || disposed || mine !== generation || busy) return;
// Während der Rückfrage kann die Sitzung gewechselt haben: dann nichts mehr senden.
if (!confirmed || disposed || mine !== generation || busy || !mayRemove(kind, c)) return;
showMessage('', 'info');
setBusy('remove');
try {
@@ -428,7 +433,7 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
async function toggleVisibility(): Promise<void> {
const c = card;
if (!c || busy) return;
if (!c || busy || !isAdmin(deps.currentUser())) return;
const mine = generation;
const show = c.hidden;
showMessage('', 'info');
+13
View File
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { formatDate } from './format';
describe('formatDate', () => {
it('schreibt dd.mm.yyyy', () => {
expect(formatDate(new Date(2026, 9, 4, 12).toISOString())).toBe('04.10.2026');
expect(formatDate(new Date(2026, 0, 31, 12).toISOString())).toBe('31.01.2026');
});
it('gibt bei unlesbarem Datum nichts zurück', () => {
expect(formatDate('kaputt')).toBe('');
});
});
+9
View File
@@ -0,0 +1,9 @@
// Gemeinsame Formatierung der Seiten (Meine Karten, Community-Detailseite).
/** `dd.mm.yyyy` in der Zeitzone des Betrachters; leer, wenn das Datum unlesbar ist. */
export function formatDate(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(date.getDate())}.${pad(date.getMonth() + 1)}.${date.getFullYear()}`;
}