feat(geschichten): frontend foundation — canBlogWrite, sanitize util, nav, i18n

- Derives canBlogWrite in +layout.server.ts the same way as canAnnotate.
- Adds Geschichten link to AppNav (desktop + mobile, between Stammbaum and Admin).
- Adds error_geschichte_not_found mapping to errors.ts and translation keys
  for the Geschichten index, detail, editor, and confirmation copy in
  de/en/es.
- Adds isomorphic-dompurify-backed safeHtml() helper with allow-list
  matching the backend OWASP policy (p/br/strong/em/h2/h3/ul/ol/li),
  plus Vitest spec.
- Updates legacy spec test data so the new required canBlogWrite layout
  prop type-checks.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-02 17:43:29 +02:00
parent afd6d0b20d
commit 9e7861fa03
18 changed files with 998 additions and 28 deletions

View File

@@ -41,6 +41,7 @@ export type ErrorCode =
| 'RELATIONSHIP_NOT_FOUND'
| 'CIRCULAR_RELATIONSHIP'
| 'DUPLICATE_RELATIONSHIP'
| 'GESCHICHTE_NOT_FOUND'
| 'MISSING_CREDENTIALS'
| 'UNAUTHORIZED'
| 'FORBIDDEN'
@@ -145,6 +146,8 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_circular_relationship();
case 'DUPLICATE_RELATIONSHIP':
return m.error_duplicate_relationship();
case 'GESCHICHTE_NOT_FOUND':
return m.error_geschichte_not_found();
case 'MISSING_CREDENTIALS':
return m.login_error_missing_credentials();
case 'UNAUTHORIZED':

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { safeHtml } from './sanitize';
describe('safeHtml', () => {
it('returns empty string for null/undefined/empty input', () => {
expect(safeHtml(null)).toBe('');
expect(safeHtml(undefined)).toBe('');
expect(safeHtml('')).toBe('');
});
it('keeps allowed tags: p, strong, em, br, h2, h3, ul, ol, li', () => {
const html =
'<p><strong>bold</strong> <em>italic</em><br>x</p>' +
'<h2>H2</h2><h3>H3</h3>' +
'<ul><li>a</li></ul><ol><li>b</li></ol>';
const result = safeHtml(html);
expect(result).toContain('<strong>bold</strong>');
expect(result).toContain('<em>italic</em>');
expect(result).toContain('<br>');
expect(result).toContain('<h2>H2</h2>');
expect(result).toContain('<h3>H3</h3>');
expect(result).toContain('<ul>');
expect(result).toContain('<ol>');
expect(result).toContain('<li>a</li>');
});
it('strips <script> tags entirely', () => {
const result = safeHtml('<p>ok</p><script>alert(1)</script>');
expect(result).not.toContain('<script>');
expect(result).not.toContain('alert');
expect(result).toContain('<p>ok</p>');
});
it('strips on* event-handler attributes', () => {
const result = safeHtml('<p onclick="evil()">x</p>');
expect(result).not.toContain('onclick');
});
it('strips disallowed elements like <img>, <a>, <iframe>', () => {
const result = safeHtml(
'<p>x</p><img src="x" onerror="alert(1)"><a href="javascript:alert(1)">link</a><iframe></iframe>'
);
expect(result).not.toContain('<img');
expect(result).not.toContain('<a ');
expect(result).not.toContain('<iframe');
});
});

View File

@@ -0,0 +1,17 @@
import DOMPurify from 'isomorphic-dompurify';
const ALLOWED_TAGS = ['p', 'br', 'strong', 'em', 'h2', 'h3', 'ul', 'ol', 'li'];
/**
* Render-side sanitiser for Geschichte body HTML. The backend already
* sanitises with the OWASP allow-list on save, but we re-run on render
* because the API can be called directly and stored content can pre-date
* a tightening of the allow-list.
*/
export function safeHtml(raw: string | null | undefined): string {
if (!raw) return '';
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS,
ALLOWED_ATTR: []
});
}

View File

@@ -7,6 +7,7 @@ export const load: LayoutServerLoad = async ({ locals }) => {
canWrite: groups.some((g) => g.permissions.includes('WRITE_ALL')),
canAnnotate: groups.some(
(g) => g.permissions.includes('WRITE_ALL') || g.permissions.includes('ANNOTATE_ALL')
)
),
canBlogWrite: groups.some((g) => g.permissions.includes('BLOG_WRITE'))
};
};

View File

@@ -68,6 +68,16 @@ function handleOverlayKeydown(event: KeyboardEvent) {
>
{m.nav_stammbaum()}
</a>
<a
href="/geschichten"
class="my-2 inline-flex items-center px-3 font-sans text-xs font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-focus-ring
{page.url.pathname.startsWith('/geschichten')
? 'border-b-2 border-accent text-white'
: 'text-white/70 hover:text-white'}"
>
{m.nav_geschichten()}
</a>
{#if isAdmin}
<a
href="/admin"
@@ -170,6 +180,16 @@ function handleOverlayKeydown(event: KeyboardEvent) {
{m.nav_stammbaum()}
</a>
<a
href="/geschichten"
class="block flex min-h-[44px] w-full items-center px-4 py-3 font-sans text-sm font-bold tracking-widest uppercase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-inset
{page.url.pathname.startsWith('/geschichten')
? 'bg-accent-bg text-ink'
: 'text-ink-2 hover:bg-muted hover:text-ink'}"
>
{m.nav_geschichten()}
</a>
{#if isAdmin}
<a
href="/admin"

View File

@@ -41,6 +41,7 @@ const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
canBlogWrite: false,
editUser: makeUser(),
groups
};

View File

@@ -10,7 +10,13 @@ const groups = [
{ id: 'g2', name: 'Admins', permissions: ['ADMIN'] }
];
const baseData = { user: undefined, canWrite: true, canAnnotate: false, groups };
const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
canBlogWrite: false,
groups
};
afterEach(cleanup);

View File

@@ -13,6 +13,7 @@ const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
canBlogWrite: false,
documents: [],
initialValues: { senderName: '', receiverName: '' },
filters: { senderId: '', receiverId: '', from: '', to: '', dir: 'DESC' as const }

View File

@@ -11,6 +11,7 @@ const baseData = {
user: undefined,
canWrite: true,
canAnnotate: false,
canBlogWrite: false,
persons: [],
initialSenderId: '',
initialSenderName: '',

View File

@@ -25,6 +25,7 @@ const makeData = (overrides = {}) => ({
},
canWrite: true,
canAnnotate: false,
canBlogWrite: false,
...overrides
});

View File

@@ -22,6 +22,7 @@ const baseData = {
} as User,
canWrite: true,
canAnnotate: false,
canBlogWrite: false,
resumeDoc: null,
pulse: null,
activityFeed: [],

View File

@@ -21,6 +21,7 @@ const emptyData = {
user: undefined,
canWrite: true,
canAnnotate: false,
canBlogWrite: false,
q: '',
persons: [],
stats: defaultStats