diff --git a/web/src/account/with-login.ts b/web/src/account/with-login.ts index 44009c8..6b9b705 100644 --- a/web/src/account/with-login.ts +++ b/web/src/account/with-login.ts @@ -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() { diff --git a/web/src/pages/cards-page.test.ts b/web/src/pages/cards-page.test.ts index 9914f7c..f560394 100644 --- a/web/src/pages/cards-page.test.ts +++ b/web/src/pages/cards-page.test.ts @@ -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('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); diff --git a/web/src/pages/cards-page.ts b/web/src/pages/cards-page.ts index 2b33ad4..dfe6c06 100644 --- a/web/src/pages/cards-page.ts +++ b/web/src/pages/cards-page.ts @@ -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; diff --git a/web/src/pages/community-page.test.ts b/web/src/pages/community-page.test.ts index 76aa381..a58d0bf 100644 --- a/web/src/pages/community-page.test.ts +++ b/web/src/pages/community-page.test.ts @@ -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 })); diff --git a/web/src/pages/community-page.ts b/web/src/pages/community-page.ts index dd2da6a..c3f5d3c 100644 --- a/web/src/pages/community-page.ts +++ b/web/src/pages/community-page.ts @@ -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 = 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 { 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 { @@ -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 { 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 { const c = card; - if (!c || busy) return; + if (!c || busy || !isAdmin(deps.currentUser())) return; const mine = generation; const show = c.hidden; showMessage('', 'info'); diff --git a/web/src/pages/format.test.ts b/web/src/pages/format.test.ts new file mode 100644 index 0000000..5d26bb5 --- /dev/null +++ b/web/src/pages/format.test.ts @@ -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(''); + }); +}); diff --git a/web/src/pages/format.ts b/web/src/pages/format.ts new file mode 100644 index 0000000..45a0852 --- /dev/null +++ b/web/src/pages/format.ts @@ -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()}`; +}