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:
1 parent
3cec7151a6
commit
be440bc8e9
7 files changed
+135
-40
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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()}`;
|
||||
}
|
||||
Reference in new issue
Block a user