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 { errorType, isUnauthorized } from './errors';
|
||||||
import { requireLogin } from './login-dialog';
|
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. */
|
/** Der Nutzer hat den Login-Dialog geschlossen, ohne sich anzumelden. */
|
||||||
export class LoginCancelled extends Error {
|
export class LoginCancelled extends Error {
|
||||||
constructor() {
|
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' };
|
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 dialog = () => document.querySelector<HTMLDialogElement>('dialog.confirm-dialog');
|
||||||
const submit = (form: HTMLFormElement) => form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
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', () => {
|
describe('Meine Karten: Zustände', () => {
|
||||||
it('abgemeldet: Hinweis und Anmelden-Knopf, keine Anfrage an den Server', async () => {
|
it('abgemeldet: Hinweis und Anmelden-Knopf, keine Anfrage an den Server', async () => {
|
||||||
mocks.getCurrentUser.mockResolvedValue(null);
|
mocks.getCurrentUser.mockResolvedValue(null);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getCurrentUser, onAuthChange } from '../account/auth';
|
|||||||
import { cardsService, normalizeName, type SavedCard } from '../account/cards';
|
import { cardsService, normalizeName, type SavedCard } from '../account/cards';
|
||||||
import { errorMessage, errorType } from '../account/errors';
|
import { errorMessage, errorType } from '../account/errors';
|
||||||
import { requireLogin } from '../account/login-dialog';
|
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 { clampCrop } from '../card/crop';
|
||||||
import { CARD_W } from '../card/geometry';
|
import { CARD_W } from '../card/geometry';
|
||||||
import { exportCard, loadCardAssets } from '../card/render';
|
import { exportCard, loadCardAssets } from '../card/render';
|
||||||
@@ -14,22 +14,14 @@ import { el } from '../editor/dom';
|
|||||||
import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download';
|
import { downloadFileName, pngSizeWarning, saveBlobAs } from '../editor/download';
|
||||||
import type { EditorStore } from '../editor/state';
|
import type { EditorStore } from '../editor/state';
|
||||||
import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage } from '../editor/upload';
|
import { PIXELS_MESSAGE, READ_MESSAGE, decodeImage } from '../editor/upload';
|
||||||
|
import { formatDate } from './format';
|
||||||
import { navigate } from './router';
|
import { navigate } from './router';
|
||||||
|
|
||||||
const LOADING = 'Karten werden geladen …';
|
const LOADING = 'Karten werden geladen …';
|
||||||
const LOGGED_OUT = 'Melde dich an, um deine Karten zu sehen.';
|
const LOGGED_OUT = 'Melde dich an, um deine Karten zu sehen.';
|
||||||
const EMPTY = 'Noch keine Karten gespeichert.';
|
const EMPTY = 'Noch keine Karten gespeichert.';
|
||||||
const LOGIN_NEEDED = 'Bitte melde dich an, um fortzufahren.';
|
|
||||||
const PREVIEW_PARALLEL = 4;
|
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 {
|
export function mountCardsPage(root: HTMLElement, store: EditorStore): () => void {
|
||||||
const service = cardsService();
|
const service = cardsService();
|
||||||
let disposed = false;
|
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', () => {
|
describe('Detailseite: Aufräumen', () => {
|
||||||
it('beim Verlassen wird der Auth-Abonnent abgemeldet, danach ändert sich nichts mehr', async () => {
|
it('beim Verlassen wird der Auth-Abonnent abgemeldet, danach ändert sich nichts mehr', async () => {
|
||||||
await mount();
|
await mount();
|
||||||
@@ -435,6 +503,22 @@ describe('Detailseite: Melden', () => {
|
|||||||
expect(reportEl()).toBeNull();
|
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 () => {
|
it('eine ausgeblendete Karte (nur Autor/Admin sehen sie) hat keinen Melden-Knopf', async () => {
|
||||||
user = owner;
|
user = owner;
|
||||||
getCard.mockResolvedValue(card({ hidden: true }));
|
getCard.mockResolvedValue(card({ hidden: true }));
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import '../community/community.css';
|
import '../community/community.css';
|
||||||
import { currentUser, getCurrentUser, isAdmin, onAuthChange, type User } from '../account/auth';
|
import { currentUser, getCurrentUser, isAdmin, onAuthChange, type User } from '../account/auth';
|
||||||
import { errorMessage } from '../account/errors';
|
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 { ApiError, reportCard, setVisibility, withdrawCard, type ReportReason } from '../community/api';
|
||||||
import {
|
import {
|
||||||
getGalleryCard,
|
getGalleryCard,
|
||||||
@@ -18,6 +18,7 @@ import { REPORT_REASONS, reportDialog } from '../community/report-dialog';
|
|||||||
import { confirmDialog, type ConfirmOptions } from '../editor/confirm-dialog';
|
import { confirmDialog, type ConfirmOptions } from '../editor/confirm-dialog';
|
||||||
import { el } from '../editor/dom';
|
import { el } from '../editor/dom';
|
||||||
import { downloadFileName, saveBlobAs } from '../editor/download';
|
import { downloadFileName, saveBlobAs } from '../editor/download';
|
||||||
|
import { formatDate } from './format';
|
||||||
import { navigate, titleFor } from './router';
|
import { navigate, titleFor } from './router';
|
||||||
|
|
||||||
/** Alles, was die Seite von außen braucht (für Tests austauschbar). */
|
/** 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_LOADING = 'Bild wird geladen …';
|
||||||
const IMAGE_ERROR = 'Das Bild konnte nicht geladen werden.';
|
const IMAGE_ERROR = 'Das Bild konnte nicht geladen werden.';
|
||||||
const REASONS_ERROR = 'Die Gründe konnten 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 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]));
|
const REASON_LABELS: ReadonlyMap<string, string> = new Map(REPORT_REASONS.map((r) => [r.value, r.label]));
|
||||||
@@ -73,14 +74,6 @@ interface ReportsState {
|
|||||||
list: GalleryReport[];
|
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“. */
|
/** „1 Meldung“, „3 Meldungen“. */
|
||||||
function reportsLabel(count: number): string {
|
function reportsLabel(count: number): string {
|
||||||
return count === 1 ? '1 Meldung' : `${count} Meldungen`;
|
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> {
|
async function reportThis(): Promise<void> {
|
||||||
const c = card;
|
const c = card;
|
||||||
if (!c || busy) return;
|
if (!c || busy) return;
|
||||||
await reportDialog({
|
try {
|
||||||
cardTitle: c.title,
|
await reportDialog({
|
||||||
signal: disposal.signal,
|
cardTitle: c.title,
|
||||||
send: (reason) => deps.reportCard(c.id, reason),
|
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> {
|
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. */
|
/** 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> {
|
async function removeCard(kind: 'withdraw' | 'delete'): Promise<void> {
|
||||||
const c = card;
|
const c = card;
|
||||||
if (!c || busy) return;
|
if (!c || busy || !mayRemove(kind, c)) return;
|
||||||
const mine = generation;
|
const mine = generation;
|
||||||
const confirmed = await ask(
|
const confirmed = await ask(
|
||||||
kind === 'withdraw'
|
kind === 'withdraw'
|
||||||
@@ -409,7 +413,8 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
|
|||||||
confirmLabel: 'Löschen',
|
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');
|
showMessage('', 'info');
|
||||||
setBusy('remove');
|
setBusy('remove');
|
||||||
try {
|
try {
|
||||||
@@ -428,7 +433,7 @@ export function mountCommunityPage(root: HTMLElement, id: string, deps: Communit
|
|||||||
|
|
||||||
async function toggleVisibility(): Promise<void> {
|
async function toggleVisibility(): Promise<void> {
|
||||||
const c = card;
|
const c = card;
|
||||||
if (!c || busy) return;
|
if (!c || busy || !isAdmin(deps.currentUser())) return;
|
||||||
const mine = generation;
|
const mine = generation;
|
||||||
const show = c.hidden;
|
const show = c.hidden;
|
||||||
showMessage('', 'info');
|
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