Compare commits
8 Commits
392af640c4
...
49443ad16a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49443ad16a | ||
|
|
e6844c403c | ||
|
|
f1932fd5f6 | ||
|
|
ba88febc77 | ||
|
|
fa7b97acdc | ||
|
|
6ef888a128 | ||
|
|
94d0733412 | ||
|
|
4ac94b2feb |
@@ -551,7 +551,6 @@
|
||||
"person_alias_btn_delete": "Entfernen",
|
||||
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
||||
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
|
||||
"error_person_rename_conflict": "Eine andere Bearbeitung hat einen verknüpften Transkriptionsblock gleichzeitig geändert. Bitte erneut versuchen.",
|
||||
"validation_last_name_required": "Nachname ist Pflichtfeld.",
|
||||
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
||||
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
||||
|
||||
@@ -551,7 +551,6 @@
|
||||
"person_alias_btn_delete": "Remove",
|
||||
"error_alias_not_found": "The name alias was not found.",
|
||||
"error_invalid_person_type": "The specified person type is not valid.",
|
||||
"error_person_rename_conflict": "Another edit modified a referenced transcription block at the same time. Please try again.",
|
||||
"validation_last_name_required": "Last name is required.",
|
||||
"validation_first_name_required": "First name is required.",
|
||||
"error_ocr_service_unavailable": "The OCR service is not available.",
|
||||
|
||||
@@ -551,7 +551,6 @@
|
||||
"person_alias_btn_delete": "Eliminar",
|
||||
"error_alias_not_found": "No se encontro el alias de nombre.",
|
||||
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
|
||||
"error_person_rename_conflict": "Otra edición modificó un bloque de transcripción referenciado al mismo tiempo. Por favor, inténtalo de nuevo.",
|
||||
"validation_last_name_required": "El apellido es obligatorio.",
|
||||
"validation_first_name_required": "El nombre es obligatorio.",
|
||||
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
||||
|
||||
@@ -122,12 +122,31 @@ function selectItem(item: Person) {
|
||||
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
|
||||
{m.person_mention_popup_empty()}
|
||||
</p>
|
||||
<!--
|
||||
Empty-state escape hatch — without it the transcriber has to close
|
||||
the dropdown, navigate to /persons/new, come back, and re-type the
|
||||
query. target=_blank keeps the document and editor state intact;
|
||||
rel=noopener prevents reverse-tabnabbing on the new tab. Leonie #5621.
|
||||
-->
|
||||
<a
|
||||
href="/persons/new"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="flex min-h-[44px] items-center gap-2 border-t border-line px-3 py-2.5 font-sans text-sm font-medium text-brand-navy hover:bg-canvas focus:bg-canvas focus:outline-none"
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
>
|
||||
{m.person_mention_create_new()}
|
||||
<span aria-hidden="true">→</span>
|
||||
</a>
|
||||
{:else}
|
||||
{#each model.items as person, i (person.id)}
|
||||
<div
|
||||
class={[
|
||||
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
|
||||
i === highlightedIndex && 'bg-brand-mint/20 ring-2 ring-brand-mint ring-inset'
|
||||
// brand-mint ring (≈2.5:1 on white) fails WCAG 1.4.11 Non-Text
|
||||
// Contrast for a meaningful keyboard-highlight indicator. brand-navy
|
||||
// gives ~14.5:1 against the bg-brand-mint/20 row. Leonie #5621.
|
||||
i === highlightedIndex && 'bg-brand-mint/20 ring-2 ring-brand-navy ring-inset'
|
||||
]}
|
||||
role="option"
|
||||
aria-selected={i === highlightedIndex}
|
||||
|
||||
@@ -82,6 +82,11 @@ onMount(() => {
|
||||
|
||||
editor = new Editor({
|
||||
element: editorEl,
|
||||
// Initial editable state honors the `disabled` prop. The reactive
|
||||
// $effect below keeps it in sync if the prop flips after mount —
|
||||
// without this, a keyboard user can tab into the contenteditable
|
||||
// even when the wrapper has pointer-events-none (WCAG 2.1.1).
|
||||
editable: !disabled,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
@@ -98,6 +103,9 @@ onMount(() => {
|
||||
}),
|
||||
CustomMention.configure({
|
||||
renderHTML({ node }) {
|
||||
// Underline color matches the read-mode .person-mention rule
|
||||
// (ink at ~50% alpha) — brand-mint on white fails WCAG 1.4.11
|
||||
// Non-Text Contrast (≈1.7:1, needs 3:1). Leonie #5621.
|
||||
return [
|
||||
'span',
|
||||
{
|
||||
@@ -105,7 +113,7 @@ onMount(() => {
|
||||
'data-person-id': node.attrs.personId,
|
||||
'data-display-name': node.attrs.displayName,
|
||||
class:
|
||||
'mention-token underline decoration-brand-mint underline-offset-2 text-brand-navy font-medium'
|
||||
'mention-token underline decoration-ink/50 underline-offset-2 text-brand-navy font-medium'
|
||||
},
|
||||
`@${node.attrs.displayName}`
|
||||
];
|
||||
@@ -115,6 +123,20 @@ onMount(() => {
|
||||
},
|
||||
suggestion: {
|
||||
char: '@',
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// EXCEPTION to frontend/CLAUDE.md "no client-side API fetch":
|
||||
// Tiptap's suggestion plugin lives entirely on the client and
|
||||
// fires on every keystroke after `@`. Routing each query through
|
||||
// a SvelteKit form action would round-trip through SSR for a
|
||||
// dropdown that needs to feel instantaneous, and a +server.ts
|
||||
// endpoint would only proxy the same call. Auth flows through
|
||||
// the Vite proxy in dev and Caddy in prod (cookie-based), so the
|
||||
// network surface is identical to a server-driven call.
|
||||
// Markus #5616: an ADR will formalise this. Open follow-up:
|
||||
// "ADR: client-side fetch exception for editor suggestion plugins."
|
||||
// Nora #5618 #3 — separate issue tracks the GET /api/persons
|
||||
// response-shape audit (PersonSummaryDTO leaks `notes`).
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
items: async ({ query }: { query: string }) => {
|
||||
if (!query) return [];
|
||||
try {
|
||||
@@ -232,11 +254,26 @@ onMount(() => {
|
||||
onDestroy(() => {
|
||||
editor?.destroy();
|
||||
});
|
||||
|
||||
// Keep editor in sync with the reactive `disabled` prop. Tiptap's setEditable
|
||||
// flips contenteditable on the inner DOM and stops accepting input — matches
|
||||
// the textarea's old `disabled` semantics for keyboard users (WCAG 2.1.1).
|
||||
//
|
||||
// Guard: setEditable triggers a ProseMirror transaction which fires onUpdate;
|
||||
// onUpdate writes through bind:value / bind:mentionedPersons. Without this
|
||||
// idempotence check, the effect would loop on every prop pass-through.
|
||||
$effect(() => {
|
||||
const shouldBeEditable = !disabled;
|
||||
if (editor && editor.isEditable !== shouldBeEditable) {
|
||||
editor.setEditable(shouldBeEditable);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="relative rounded-sm border border-transparent focus-within:border-brand-mint focus-within:ring-2 focus-within:ring-brand-mint/40"
|
||||
class:opacity-50={disabled}
|
||||
class:pointer-events-none={disabled}
|
||||
aria-disabled={disabled ? 'true' : undefined}
|
||||
bind:this={editorEl}
|
||||
></div>
|
||||
|
||||
@@ -47,7 +47,9 @@ function mockFetchEmpty() {
|
||||
|
||||
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
|
||||
|
||||
function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[] } = {}) {
|
||||
function renderHost(
|
||||
initial: { value?: string; mentionedPersons?: PersonMention[]; disabled?: boolean } = {}
|
||||
) {
|
||||
let snapshot: Snapshot = {
|
||||
value: initial.value ?? '',
|
||||
mentionedPersons: initial.mentionedPersons ?? []
|
||||
@@ -55,6 +57,7 @@ function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[
|
||||
render(PersonMentionEditorHost, {
|
||||
initialValue: initial.value ?? '',
|
||||
initialMentions: initial.mentionedPersons ?? [],
|
||||
disabled: initial.disabled ?? false,
|
||||
onChange: (snap: Snapshot) => {
|
||||
snapshot = snap;
|
||||
}
|
||||
@@ -142,6 +145,19 @@ describe('PersonMentionEditor — typeahead', () => {
|
||||
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('offers a "create new person" link in the empty state', async () => {
|
||||
mockFetchEmpty();
|
||||
renderHost();
|
||||
|
||||
await userEvent.type(page.getByRole('textbox'), '@xyz');
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
const link = page.getByRole('link', { name: /Neue Person anlegen/ });
|
||||
await expect.element(link).toBeVisible();
|
||||
await expect.element(link).toHaveAttribute('href', '/persons/new');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
|
||||
@@ -280,6 +296,73 @@ describe('PersonMentionEditor — keyboard navigation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Disabled state (WCAG 2.1.1 — keyboard users) ────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — disabled state', () => {
|
||||
it('sets contenteditable=false on the editor when disabled', async () => {
|
||||
renderHost({ value: 'Bestehender Text', disabled: true });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
||||
expect(textbox).not.toBeNull();
|
||||
expect(textbox!.getAttribute('contenteditable')).toBe('false');
|
||||
});
|
||||
});
|
||||
|
||||
it('exposes aria-disabled=true on the editor wrapper when disabled', async () => {
|
||||
renderHost({ disabled: true });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const wrapper = document.querySelector('[aria-disabled="true"]');
|
||||
expect(wrapper).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the editor editable (contenteditable=true) when not disabled', async () => {
|
||||
renderHost({ disabled: false });
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
||||
expect(textbox).not.toBeNull();
|
||||
expect(textbox!.getAttribute('contenteditable')).toBe('true');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Security — XSS in displayName (CWE-79) ──────────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — XSS resistance', () => {
|
||||
it('renders a malicious displayName as text, not as HTML elements', async () => {
|
||||
// A historical sidecar entry whose displayName contains an HTML payload
|
||||
// that would execute if interpolated as raw HTML. Tiptap's renderHTML
|
||||
// returns the @-prefixed string as the third tuple entry, which
|
||||
// ProseMirror's DOMSerializer treats as a Text node — escaping it.
|
||||
const maliciousMention: PersonMention = {
|
||||
personId: '00000000-0000-0000-0000-000000000001',
|
||||
displayName: '<img src=x onerror=alert(1)>'
|
||||
};
|
||||
|
||||
renderHost({
|
||||
value: '@<img src=x onerror=alert(1)>',
|
||||
mentionedPersons: [maliciousMention]
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
|
||||
expect(textbox).not.toBeNull();
|
||||
// No element from the malicious payload should have appeared as a real
|
||||
// DOM node. (Tiptap inserts its own ProseMirror-separator <img> in empty
|
||||
// paragraphs — that is internal markup and never carries user attrs;
|
||||
// guard against the injection by checking the user-controlled attrs.)
|
||||
expect(textbox!.querySelector('img[onerror]')).toBeNull();
|
||||
expect(textbox!.querySelector('img[src="x"]')).toBeNull();
|
||||
expect(textbox!.querySelector('script')).toBeNull();
|
||||
// The payload should appear as visible text content instead.
|
||||
expect(textbox!.textContent ?? '').toContain('<img src=x onerror=alert(1)>');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
|
||||
|
||||
describe('PersonMentionEditor — touch target', () => {
|
||||
|
||||
@@ -9,10 +9,17 @@ type Props = {
|
||||
initialValue?: string;
|
||||
initialMentions?: PersonMention[];
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void;
|
||||
};
|
||||
|
||||
let { initialValue = '', initialMentions = [], placeholder, onChange }: Props = $props();
|
||||
let {
|
||||
initialValue = '',
|
||||
initialMentions = [],
|
||||
placeholder,
|
||||
disabled = false,
|
||||
onChange
|
||||
}: Props = $props();
|
||||
|
||||
// initial* props seed mount-time state; reading them inside untrack signals
|
||||
// the intentional one-shot capture and silences state_referenced_locally.
|
||||
@@ -28,4 +35,5 @@ $effect(() => {
|
||||
bind:value={value}
|
||||
bind:mentionedPersons={mentionedPersons}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { detectPersonMention } from './personMention';
|
||||
|
||||
describe('detectPersonMention', () => {
|
||||
it('returns null when text has no @', () => {
|
||||
expect(detectPersonMention('hello world', 11)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when @ is preceded by a non-whitespace character (email pattern)', () => {
|
||||
expect(detectPersonMention('user@example', 12)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns query for @ at the very start of string', () => {
|
||||
expect(detectPersonMention('@Aug', 4)).toBe('Aug');
|
||||
});
|
||||
|
||||
it('returns empty string immediately after @', () => {
|
||||
expect(detectPersonMention('@', 1)).toBe('');
|
||||
});
|
||||
|
||||
it('returns single-word query', () => {
|
||||
expect(detectPersonMention('hi @Auguste', 11)).toBe('Auguste');
|
||||
});
|
||||
|
||||
it('keeps the trigger active when the query has a trailing space', () => {
|
||||
expect(detectPersonMention('hi @Auguste ', 12)).toBe('Auguste ');
|
||||
});
|
||||
|
||||
it('returns multi-word query (spaces allowed)', () => {
|
||||
expect(detectPersonMention('hi @Auguste Raddatz', 19)).toBe('Auguste Raddatz');
|
||||
});
|
||||
|
||||
it('returns single-character query', () => {
|
||||
expect(detectPersonMention('@M', 2)).toBe('M');
|
||||
});
|
||||
|
||||
it('returns null when the query crosses a newline', () => {
|
||||
expect(detectPersonMention('@Aug\nfoo', 8)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when a second @ appears in the query (next mention starts)', () => {
|
||||
expect(detectPersonMention('@Aug@bar', 8)).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the most recent @ when separated by whitespace', () => {
|
||||
// '@Aug @Bert' with cursor at end — the second @ is the trigger.
|
||||
expect(detectPersonMention('@Aug @Bert', 10)).toBe('Bert');
|
||||
});
|
||||
|
||||
it('returns the query when the cursor sits exactly at a newline boundary', () => {
|
||||
// '@Aug\nfoo' with cursor at index 4 — right at the newline before it
|
||||
// is consumed. The query is still 'Aug' because nothing past the cursor
|
||||
// counts.
|
||||
expect(detectPersonMention('@Aug\nfoo', 4)).toBe('Aug');
|
||||
});
|
||||
|
||||
it('returns null when cursor is before the @', () => {
|
||||
expect(detectPersonMention('@Hans', 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('uses the most recent @ in the text', () => {
|
||||
// cursor is just after the second @ + a few chars
|
||||
expect(detectPersonMention('hi @Anna and @Bert', 18)).toBe('Bert');
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Given the current textarea value and cursor position, returns the
|
||||
* @-person-mention query being typed (the text after the last triggering @),
|
||||
* or null if no person-mention is active.
|
||||
*
|
||||
* Rules — distinct from comment-mentions in `mention.ts`:
|
||||
* - @ must be at the start of the string or preceded by whitespace
|
||||
* - The query may contain spaces (historical persons commonly have multi-word
|
||||
* display names — "Auguste Raddatz", "Maria von Müller-Schultz")
|
||||
* - The query stops at a newline or at a second @ (the next mention starts)
|
||||
*/
|
||||
export function detectPersonMention(text: string, cursorPos: number): string | null {
|
||||
const before = text.slice(0, cursorPos);
|
||||
const atIndex = before.lastIndexOf('@');
|
||||
if (atIndex === -1) return null;
|
||||
|
||||
if (atIndex > 0 && !/\s/.test(before[atIndex - 1])) return null;
|
||||
|
||||
const query = before.slice(atIndex + 1);
|
||||
if (query.includes('\n') || query.includes('@')) return null;
|
||||
|
||||
return query;
|
||||
}
|
||||
Reference in New Issue
Block a user