refactor(timeline): single-source the curator edit gate via canEditEvent
The security-relevant edit-affordance gate (canWrite && !derived && eventId != null) was copied into EventPill, WorldBand, and EventCluster — three places for one load-bearing contract, inviting drift. It now lives once as canEditEvent(entry, canWrite) in eventCardConfig, and all three call it. No behavior change (HISTORICAL is never derived, so WorldBand's gate is unchanged). First half of review finding #5 (Architect-1). Refs #850 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import * as m from '$lib/paraglide/messages.js';
|
||||
import LetterCard from './LetterCard.svelte';
|
||||
import GlyphLabel from './GlyphLabel.svelte';
|
||||
import { entryKey } from './entryKey';
|
||||
import { getAccentConfig } from './eventCardConfig';
|
||||
import { getAccentConfig, canEditEvent } from './eventCardConfig';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
import { CLUSTER_PREVIEW } from './eventClustering';
|
||||
import type { components } from '$lib/generated/api';
|
||||
@@ -51,7 +51,7 @@ const provenance = $derived(
|
||||
event?.derived ? m.timeline_provenance_derived() : m.timeline_provenance_curated()
|
||||
);
|
||||
const eventSubtitle = $derived(eventDateLabel ? `${eventDateLabel} · ${provenance}` : provenance);
|
||||
const canEdit = $derived(canWrite && !!event && !event.derived && event.eventId != null);
|
||||
const canEdit = $derived(!!event && canEditEvent(event, canWrite));
|
||||
|
||||
// First-5 preview + show-more (REQ-003): a large cluster stays readable instead of dumping every
|
||||
// card into the timeline.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { getAccentConfig } from './eventCardConfig';
|
||||
import { getAccentConfig, canEditEvent } from './eventCardConfig';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
@@ -26,7 +26,7 @@ const provenance = $derived(
|
||||
// Provenance always shows; the date is an optional prefix so an undated event
|
||||
// still reads "abgeleitet"/"kuratiert" (REQ-007).
|
||||
const subtitle = $derived(dateLabel ? `${dateLabel} · ${provenance}` : provenance);
|
||||
const canEdit = $derived(canWrite && !entry.derived && entry.eventId != null);
|
||||
const canEdit = $derived(canEditEvent(entry, canWrite));
|
||||
</script>
|
||||
|
||||
<div class="flex justify-center">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as m from '$lib/paraglide/messages.js';
|
||||
import { getAccentConfig } from './eventCardConfig';
|
||||
import { getAccentConfig, canEditEvent } from './eventCardConfig';
|
||||
import { timelineDateLabel } from './dateLabel';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
@@ -26,9 +26,9 @@ const dateText = $derived(showSpan ? null : entry.precision === 'RANGE' ? fromYe
|
||||
// Every WorldBand is a HISTORICAL band, so the visible "historisch" register
|
||||
// always trails the subtitle as plain text — never a second pill (REQ-009).
|
||||
const historical = $derived(m.timeline_layer_historical_suffix());
|
||||
// A HISTORICAL event is never derived, so the edit gate is just the curator
|
||||
// flag plus a real eventId (#842 REQ-006/008).
|
||||
const canEdit = $derived(canWrite && entry.eventId != null);
|
||||
// A HISTORICAL event is never derived, so canEditEvent's derived check is a
|
||||
// no-op here — the gate is the curator flag plus a real eventId (#842 REQ-006/008).
|
||||
const canEdit = $derived(canEditEvent(entry, canWrite));
|
||||
</script>
|
||||
|
||||
<div class="my-3 border-y border-line bg-canvas px-4 py-2 text-center">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getAccentConfig } from './eventCardConfig';
|
||||
import { getAccentConfig, canEditEvent } from './eventCardConfig';
|
||||
import type { components } from '$lib/generated/api';
|
||||
|
||||
type TimelineEntryDTO = components['schemas']['TimelineEntryDTO'];
|
||||
@@ -51,3 +51,24 @@ describe('getAccentConfig', () => {
|
||||
expect(cfg.accent).toBe('curated');
|
||||
});
|
||||
});
|
||||
|
||||
// The single source of the curator edit-affordance gate (CLAUDE.md's TimelineEntryDTO contract):
|
||||
// a curated event shows its edit pencil only for a writer, never for a derived life-event or a
|
||||
// null eventId. Shared by EventPill, WorldBand, and EventCluster (#850 finding #5).
|
||||
describe('canEditEvent', () => {
|
||||
it('allows a writer to edit a curated event with an eventId', () => {
|
||||
expect(canEditEvent(event({ derived: false, eventId: 'e-1' }), true)).toBe(true);
|
||||
});
|
||||
|
||||
it('denies a viewer without write permission', () => {
|
||||
expect(canEditEvent(event({ derived: false, eventId: 'e-1' }), false)).toBe(false);
|
||||
});
|
||||
|
||||
it('denies a derived life-event even for a writer', () => {
|
||||
expect(canEditEvent(event({ derived: true, eventId: 'e-1' }), true)).toBe(false);
|
||||
});
|
||||
|
||||
it('denies an event with no eventId even for a writer', () => {
|
||||
expect(canEditEvent(event({ derived: false, eventId: undefined }), true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,3 +36,16 @@ export function getAccentConfig(entry: TimelineEntryDTO): AccentConfig {
|
||||
}
|
||||
return { glyph: '★', label: m.timeline_layer_family(), accent: 'curated' };
|
||||
}
|
||||
|
||||
/**
|
||||
* The curator edit-affordance gate, in one place — the security-relevant contract documented on
|
||||
* CLAUDE.md's `TimelineEntryDTO` row (`derived || eventId == null` → no edit link). A curated
|
||||
* event's edit pencil shows only for a viewer with WRITE_ALL (`canWrite`), and only when it is a
|
||||
* real curated event: never a derived life-event (nothing to edit) and never a null `eventId`.
|
||||
* HISTORICAL events are never derived, so this also covers the world band. The gate is UX only —
|
||||
* the #781 route guard + backend permission are the real boundary. Shared by EventPill, WorldBand,
|
||||
* and EventCluster so the gate has a single source of truth (#850 finding #5).
|
||||
*/
|
||||
export function canEditEvent(entry: TimelineEntryDTO, canWrite: boolean): boolean {
|
||||
return canWrite && !entry.derived && entry.eventId != null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user