Private
Public Access
1
0

Security hardening and fix all compile warnings

Security fixes:
- Validate theme background image URLs against whitelist pattern
- Add board permission check to Socket.IO join-board handler
- Disable raw HTML passthrough in markdown parser (XSS prevention)
- Add UUID validation on avatar and branding route params (path traversal)
- Add security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy)
- Validate webhook URLs (HTTPS only, block internal addresses)
- Add import payload size limit (5MB)
- Remove server uptime from health endpoint
- Make seed password configurable via SEED_ADMIN_PASSWORD env var
- Patch DOMPurify dependency vulnerability via npm audit fix

Warning fixes:
- Convert $state(prop) to $effect.pre sync pattern (state_referenced_locally)
- Add for/id pairings to form labels (a11y_label_has_associated_control)
- Add svelte-ignore for intentional autofocus usage (a11y_autofocus)
- Add ARIA roles to interactive/overlay divs (a11y_no_static_element_interactions)
- Add aria-label to icon-only buttons (a11y_consider_explicit_label)
- Add keyboard handler alongside click events (a11y_click_events_have_key_events)
- Fix non-reactive fileInput declaration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-03-07 18:36:47 -05:00
parent 8f79de1df6
commit d418c367cb
37 changed files with 227 additions and 78 deletions
+9 -6
View File
@@ -1051,9 +1051,9 @@
} }
}, },
"node_modules/@sveltejs/kit": { "node_modules/@sveltejs/kit": {
"version": "2.53.2", "version": "2.53.4",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.2.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.4.tgz",
"integrity": "sha512-M+MqAvFve12T1HWws/2npP/s3hFtyjw3GB/OXW/8a1jZBk48qnvPJrtgE+VOMc3RnjUMxc4mv/vQ73nvj2uNMg==", "integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
@@ -2025,10 +2025,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.3.1", "version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"license": "(MPL-2.0 OR Apache-2.0)", "license": "(MPL-2.0 OR Apache-2.0)",
"engines": {
"node": ">=20"
},
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
} }
+3 -2
View File
@@ -22,7 +22,8 @@ async function main() {
} }
// Create admin user // Create admin user
const passwordHash = await bcrypt.hash('admin123', 12); const seedPassword = process.env.SEED_ADMIN_PASSWORD || 'admin123';
const passwordHash = await bcrypt.hash(seedPassword, 12);
await prisma.user.upsert({ await prisma.user.upsert({
where: { tenantId_email: { tenantId: tenant.id, email: 'admin@example.com' } }, where: { tenantId_email: { tenantId: tenant.id, email: 'admin@example.com' } },
update: {}, update: {},
@@ -90,7 +91,7 @@ async function main() {
} }
console.log('Seed complete.'); console.log('Seed complete.');
console.log(' Admin: admin@example.com / admin123'); console.log(' Admin: admin@example.com (password set via SEED_ADMIN_PASSWORD env var)');
} }
main() main()
+23 -1
View File
@@ -87,7 +87,29 @@ io.on('connection', (socket) => {
// Track which boards this socket has joined (for cleanup on disconnect) // Track which boards this socket has joined (for cleanup on disconnect)
const joinedBoards = new Set(); const joinedBoards = new Set();
socket.on('join-board', (boardId) => { socket.on('join-board', async (boardId) => {
// Validate boardId format (UUID)
if (typeof boardId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(boardId)) {
return;
}
// Check board access permission
try {
const board = await prisma.board.findUnique({
where: { id: boardId },
select: { visibility: true, members: { select: { userId: true } } }
});
if (!board) return;
if (board.visibility !== 'PUBLIC') {
if (!user) return;
const isMember = board.members.some((m) => m.userId === user.id);
if (!isMember && user.tenantRole !== 'ADMIN' && user.globalRole !== 'SITE_ADMIN') return;
}
} catch (err) {
logger.error({ err, boardId }, 'Board access check failed');
return;
}
socket.join(`board:${boardId}`); socket.join(`board:${boardId}`);
joinedBoards.add(boardId); joinedBoards.add(boardId);
+5
View File
@@ -67,6 +67,11 @@ export const handle: Handle = async ({ event, resolve }) => {
: undefined : undefined
}); });
// Security headers
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
logger.info({ logger.info({
method: event.request.method, method: event.request.method,
path: event.url.pathname, path: event.url.pathname,
+2 -2
View File
@@ -64,7 +64,7 @@
<Avatar name={assignee.user.name} avatarUrl={assignee.user.avatarUrl} size="xs" /> <Avatar name={assignee.user.name} avatarUrl={assignee.user.avatarUrl} size="xs" />
<span class="text-xs text-gray-700 dark:text-gray-300">{assignee.user.name}</span> <span class="text-xs text-gray-700 dark:text-gray-300">{assignee.user.name}</span>
{#if canEdit} {#if canEdit}
<button onclick={() => removeAssignee(assignee.userId)} class="text-gray-400 hover:text-red-500 ml-0.5"> <button onclick={() => removeAssignee(assignee.userId)} class="text-gray-400 hover:text-red-500 ml-0.5" aria-label="Remove assignee {assignee.user.name}">
<svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"> <svg class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg> </svg>
@@ -82,7 +82,7 @@
</button> </button>
{#if showDropdown} {#if showDropdown}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="fixed inset-0 z-10" onclick={() => (showDropdown = false)}></div> <div class="fixed inset-0 z-10" role="presentation" onclick={() => (showDropdown = false)}></div>
<div class="absolute right-0 top-full mt-1 z-20 w-52 rounded-lg bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700 p-1"> <div class="absolute right-0 top-full mt-1 z-20 w-52 rounded-lg bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700 p-1">
<div class="max-h-48 overflow-y-auto"> <div class="max-h-48 overflow-y-auto">
{#each boardMembers as member} {#each boardMembers as member}
+2 -2
View File
@@ -19,11 +19,11 @@
let { cardId, boardId, columnId, attachments = [], canEdit }: Props = $props(); let { cardId, boardId, columnId, attachments = [], canEdit }: Props = $props();
let localAttachments = $state<Attachment[]>(attachments); let localAttachments = $state<Attachment[]>([]);
let uploading = $state(false); let uploading = $state(false);
let uploadProgress = $state(0); let uploadProgress = $state(0);
$effect(() => { $effect.pre(() => {
localAttachments = attachments; localAttachments = attachments;
}); });
+5 -5
View File
@@ -147,16 +147,16 @@
<div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-3 space-y-2"> <div class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 p-3 space-y-2">
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<div> <div>
<label class="block text-[10px] text-gray-500 dark:text-gray-400 mb-0.5">When</label> <label for="automation-trigger" class="block text-[10px] text-gray-500 dark:text-gray-400 mb-0.5">When</label>
<select bind:value={newTrigger} class="w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-xs"> <select id="automation-trigger" bind:value={newTrigger} class="w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-xs">
{#each triggers as t} {#each triggers as t}
<option value={t.value}>{t.label}</option> <option value={t.value}>{t.label}</option>
{/each} {/each}
</select> </select>
</div> </div>
<div> <div>
<label class="block text-[10px] text-gray-500 dark:text-gray-400 mb-0.5">Then</label> <label for="automation-action" class="block text-[10px] text-gray-500 dark:text-gray-400 mb-0.5">Then</label>
<select bind:value={newAction} class="w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-xs"> <select id="automation-action" bind:value={newAction} class="w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 px-2 py-1 text-xs">
{#each actions as a} {#each actions as a}
<option value={a.value}>{a.label}</option> <option value={a.value}>{a.label}</option>
{/each} {/each}
@@ -244,7 +244,7 @@
class="rounded border-gray-300 dark:border-gray-600 text-[var(--color-primary)] focus:ring-[var(--color-primary)]" class="rounded border-gray-300 dark:border-gray-600 text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/> />
</label> </label>
<button onclick={() => deleteRule(rule.id)} class="text-red-500 hover:text-red-600 flex-shrink-0"> <button onclick={() => deleteRule(rule.id)} class="text-red-500 hover:text-red-600 flex-shrink-0" aria-label="Delete rule">
<svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor"> <svg class="w-3.5 h-3.5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" /> <path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd" />
</svg> </svg>
@@ -7,7 +7,11 @@
let open = $state(false); let open = $state(false);
let customMode = $state(false); let customMode = $state(false);
let customValue = $state(currentBackground || ''); let customValue = $state('');
$effect.pre(() => {
customValue = currentBackground || '';
});
const PRESET_SOLIDS = [ const PRESET_SOLIDS = [
'#0079bf', '#d29034', '#519839', '#b04632', '#0079bf', '#d29034', '#519839', '#b04632',
@@ -69,9 +73,12 @@
</button> </button>
{#if open} {#if open}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div <div
class="absolute top-full left-0 mt-1 z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-xl p-3 space-y-3" class="absolute top-full left-0 mt-1 z-50 w-64 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-xl p-3 space-y-3"
role="presentation"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
> >
{#if !customMode} {#if !customMode}
<div> <div>
@@ -96,6 +103,7 @@
onclick={() => setBackground(grad)} onclick={() => setBackground(grad)}
class="w-full h-8 rounded-lg border-2 transition hover:scale-105 {currentBackground === grad ? 'border-white ring-2 ring-[var(--color-primary)]' : 'border-transparent'}" class="w-full h-8 rounded-lg border-2 transition hover:scale-105 {currentBackground === grad ? 'border-white ring-2 ring-[var(--color-primary)]' : 'border-transparent'}"
style="background: {grad};" style="background: {grad};"
aria-label="Select gradient background"
></button> ></button>
{/each} {/each}
</div> </div>
+12 -4
View File
@@ -14,11 +14,18 @@
let { board, categories, canDelete, onclose, onsave }: Props = $props(); let { board, categories, canDelete, onclose, onsave }: Props = $props();
let title = $state(board.title); let title = $state('');
let description = $state(board.description || ''); let description = $state('');
let visibility = $state(board.visibility); let visibility = $state('PRIVATE');
let categoryId = $state(board.categoryId || ''); let categoryId = $state('');
let saving = $state(false); let saving = $state(false);
$effect.pre(() => {
title = board.title;
description = board.description || '';
visibility = board.visibility;
categoryId = board.categoryId || '';
});
let confirmingDelete = $state(false); let confirmingDelete = $state(false);
let deleting = $state(false); let deleting = $state(false);
@@ -65,6 +72,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div <div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-12 overflow-y-auto" class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-12 overflow-y-auto"
role="presentation"
onclick={handleBackdropClick} onclick={handleBackdropClick}
> >
<div <div
+7 -1
View File
@@ -31,9 +31,13 @@
let { card, boardId, columnId, canEdit, boardMembers, currentUserId, cardPrefix, onclose, ondelete, onupdate, oncopied }: Props = $props(); let { card, boardId, columnId, canEdit, boardMembers, currentUserId, cardPrefix, onclose, ondelete, onupdate, oncopied }: Props = $props();
let editingTitle = $state(false); let editingTitle = $state(false);
let title = $state(card.title); let title = $state('');
let saving = $state(false); let saving = $state(false);
$effect.pre(() => {
title = card.title;
});
// Full card data (lazy-loaded) // Full card data (lazy-loaded)
let fullCard = $state<any>(null); let fullCard = $state<any>(null);
let loadingFull = $state(true); let loadingFull = $state(true);
@@ -173,6 +177,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div <div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-12 overflow-y-auto" class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 p-4 pt-12 overflow-y-auto"
role="presentation"
onclick={handleBackdropClick} onclick={handleBackdropClick}
> >
<div class="w-full max-w-4xl bg-white dark:bg-gray-900 rounded-xl shadow-2xl max-sm:min-h-screen max-sm:rounded-none" role="dialog" aria-modal="true" aria-labelledby="card-modal-title" use:trapFocus> <div class="w-full max-w-4xl bg-white dark:bg-gray-900 rounded-xl shadow-2xl max-sm:min-h-screen max-sm:rounded-none" role="dialog" aria-modal="true" aria-labelledby="card-modal-title" use:trapFocus>
@@ -184,6 +189,7 @@
{/if} {/if}
{#if editingTitle && canEdit} {#if editingTitle && canEdit}
<form onsubmit={(e) => { e.preventDefault(); saveTitle(); }}> <form onsubmit={(e) => { e.preventDefault(); saveTitle(); }}>
<!-- svelte-ignore a11y_autofocus -->
<input <input
bind:value={title} bind:value={title}
class="text-lg font-semibold w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-2 py-1" class="text-lg font-semibold w-full rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-2 py-1"
@@ -254,6 +254,7 @@
{#if canEdit} {#if canEdit}
{#if addingChecklist} {#if addingChecklist}
<form onsubmit={(e) => { e.preventDefault(); addChecklist(); }} class="flex gap-1 mt-2"> <form onsubmit={(e) => { e.preventDefault(); addChecklist(); }} class="flex gap-1 mt-2">
<!-- svelte-ignore a11y_autofocus -->
<input <input
bind:value={newChecklistTitle} bind:value={newChecklistTitle}
placeholder="Checklist title..." placeholder="Checklist title..."
+2 -1
View File
@@ -72,7 +72,7 @@
<div class="space-y-1.5"> <div class="space-y-1.5">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400">{label}</label> <label for="color-picker-{label}" class="block text-xs font-medium text-gray-600 dark:text-gray-400">{label}</label>
{#if value} {#if value}
<button onclick={resetToDefault} class="text-[10px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"> <button onclick={resetToDefault} class="text-[10px] text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
{defaultValue ? 'Reset to default' : 'Clear'} {defaultValue ? 'Reset to default' : 'Clear'}
@@ -108,6 +108,7 @@
class="h-8 w-10 cursor-pointer rounded border border-gray-300 dark:border-gray-600 p-0.5" class="h-8 w-10 cursor-pointer rounded border border-gray-300 dark:border-gray-600 p-0.5"
/> />
<input <input
id="color-picker-{label}"
type="text" type="text"
bind:value={color} bind:value={color}
oninput={emitColor} oninput={emitColor}
+1
View File
@@ -38,6 +38,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div <div
class="absolute top-full right-0 mt-1 z-50 w-40 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-xl py-1" class="absolute top-full right-0 mt-1 z-50 w-40 rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-xl py-1"
role="presentation"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
> >
{#each options as opt} {#each options as opt}
+5 -5
View File
@@ -25,22 +25,22 @@
let { cardId, boardId, columnId, comments = [], currentUserId, canEdit }: Props = $props(); let { cardId, boardId, columnId, comments = [], currentUserId, canEdit }: Props = $props();
let localComments = $state<Comment[]>(comments); let localComments = $state<Comment[]>([]);
let newContent = $state(''); let newContent = $state('');
let submitting = $state(false); let submitting = $state(false);
let editingId = $state<string | null>(null); let editingId = $state<string | null>(null);
let editContent = $state(''); let editContent = $state('');
let ready = $state(false); let ready = $state(false);
$effect.pre(() => {
localComments = comments;
});
onMount(async () => { onMount(async () => {
await initDOMPurify(); await initDOMPurify();
ready = true; ready = true;
}); });
$effect(() => {
localComments = comments;
});
const basePath = $derived(`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/comments`); const basePath = $derived(`/api/boards/${boardId}/columns/${columnId}/cards/${cardId}/comments`);
async function addComment() { async function addComment() {
+8 -1
View File
@@ -9,7 +9,11 @@
let { field, value, canEdit, onsave }: Props = $props(); let { field, value, canEdit, onsave }: Props = $props();
let editing = $state(false); let editing = $state(false);
let editValue = $state(value); let editValue = $state('');
$effect.pre(() => {
editValue = value;
});
function startEdit() { function startEdit() {
if (!canEdit) return; if (!canEdit) return;
@@ -36,6 +40,7 @@
{#if editing} {#if editing}
<div class="flex-1 flex items-center gap-1"> <div class="flex-1 flex items-center gap-1">
{#if field.type === 'TEXT'} {#if field.type === 'TEXT'}
<!-- svelte-ignore a11y_autofocus -->
<input <input
type="text" type="text"
bind:value={editValue} bind:value={editValue}
@@ -44,6 +49,7 @@
onkeydown={(e) => { if (e.key === 'Enter') save(); if (e.key === 'Escape') cancel(); }} onkeydown={(e) => { if (e.key === 'Enter') save(); if (e.key === 'Escape') cancel(); }}
/> />
{:else if field.type === 'NUMBER'} {:else if field.type === 'NUMBER'}
<!-- svelte-ignore a11y_autofocus -->
<input <input
type="number" type="number"
bind:value={editValue} bind:value={editValue}
@@ -52,6 +58,7 @@
onkeydown={(e) => { if (e.key === 'Enter') save(); if (e.key === 'Escape') cancel(); }} onkeydown={(e) => { if (e.key === 'Enter') save(); if (e.key === 'Escape') cancel(); }}
/> />
{:else if field.type === 'DATE'} {:else if field.type === 'DATE'}
<!-- svelte-ignore a11y_autofocus -->
<input <input
type="date" type="date"
bind:value={editValue} bind:value={editValue}
@@ -151,6 +151,7 @@
<p class="text-[10px] uppercase text-gray-400 dark:text-gray-500 mb-1"> <p class="text-[10px] uppercase text-gray-400 dark:text-gray-500 mb-1">
{adding === 'blocks' ? 'This card blocks...' : 'This card is blocked by...'} {adding === 'blocks' ? 'This card blocks...' : 'This card is blocked by...'}
</p> </p>
<!-- svelte-ignore a11y_autofocus -->
<input <input
type="text" type="text"
placeholder="Search cards..." placeholder="Search cards..."
+2 -1
View File
@@ -63,10 +63,11 @@
</script> </script>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400">{label}</label> <label for="image-upload-{label}" class="block text-xs font-medium text-gray-600 dark:text-gray-400">{label}</label>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <input
bind:this={fileInput} bind:this={fileInput}
id="image-upload-{label}"
type="file" type="file"
accept="image/*" accept="image/*"
onchange={handleUpload} onchange={handleUpload}
@@ -65,6 +65,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div <div
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/50 p-4" class="fixed inset-0 z-[90] flex items-center justify-center bg-black/50 p-4"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) showHelp = false; }} onclick={(e) => { if (e.target === e.currentTarget) showHelp = false; }}
> >
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-sm p-6"> <div class="bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-sm p-6">
+7 -6
View File
@@ -11,10 +11,14 @@
let { value, readonly = false, onsave }: Props = $props(); let { value, readonly = false, onsave }: Props = $props();
let editing = $state(false); let editing = $state(false);
let draft = $state(value); let draft = $state('');
let previewing = $state(false); let previewing = $state(false);
let ready = $state(false); let ready = $state(false);
$effect.pre(() => {
if (!editing) draft = value;
});
onMount(async () => { onMount(async () => {
await initDOMPurify(); await initDOMPurify();
ready = true; ready = true;
@@ -31,11 +35,6 @@
editing = false; editing = false;
previewing = false; previewing = false;
} }
// Keep draft in sync when value prop changes externally
$effect(() => {
if (!editing) draft = value;
});
</script> </script>
{#if editing && !readonly} {#if editing && !readonly}
@@ -89,6 +88,8 @@
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div <div
class="{!readonly ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded' : ''} p-2 -mx-2 min-h-[40px]" class="{!readonly ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded' : ''} p-2 -mx-2 min-h-[40px]"
role="button"
tabindex="0"
onclick={() => { if (!readonly) editing = true; }} onclick={() => { if (!readonly) editing = true; }}
> >
{#if value} {#if value}
+2
View File
@@ -120,6 +120,7 @@
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div <div
class="fixed inset-0 z-10" class="fixed inset-0 z-10"
role="presentation"
onclick={() => (showDropdown = false)} onclick={() => (showDropdown = false)}
></div> ></div>
<div class="absolute left-0 top-full mt-1 z-20 w-56 rounded-lg bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700 p-2"> <div class="absolute left-0 top-full mt-1 z-20 w-56 rounded-lg bg-white dark:bg-gray-800 shadow-lg border border-gray-200 dark:border-gray-700 p-2">
@@ -150,6 +151,7 @@
class="w-5 h-5 rounded-full border-2 {newTagColor === color ? 'border-gray-600' : 'border-transparent'}" class="w-5 h-5 rounded-full border-2 {newTagColor === color ? 'border-gray-600' : 'border-transparent'}"
style="background-color: {color}" style="background-color: {color}"
onclick={() => (newTagColor = color)} onclick={() => (newTagColor = color)}
aria-label="Select color {color}"
></button> ></button>
{/each} {/each}
</div> </div>
+9 -4
View File
@@ -1,5 +1,10 @@
import type { TenantThemeData } from '../../app.d.js'; import type { TenantThemeData } from '../../app.d.js';
/** Validates a theme background image URL is safe for CSS injection. */
function isSafeCssUrl(url: string): boolean {
return /^\/api\/branding\/[0-9a-f-]+\/[a-z-]+$/.test(url);
}
/** /**
* Maps theme fields to CSS custom property names. * Maps theme fields to CSS custom property names.
* Light-prefixed fields go into :root {}, dark-prefixed into html.dark {}. * Light-prefixed fields go into :root {}, dark-prefixed into html.dark {}.
@@ -94,16 +99,16 @@ export function generateTenantCSS(theme: TenantThemeData | null | undefined): st
// Handle background images — generate class-level overrides // Handle background images — generate class-level overrides
const extraRules: string[] = []; const extraRules: string[] = [];
if (theme.lightPageBgImage) { if (theme.lightPageBgImage && isSafeCssUrl(theme.lightPageBgImage)) {
extraRules.push(`.layout-page-bg { background-image: url('${theme.lightPageBgImage}'); }`); extraRules.push(`.layout-page-bg { background-image: url('${theme.lightPageBgImage}'); }`);
} }
if (theme.lightNavBgImage) { if (theme.lightNavBgImage && isSafeCssUrl(theme.lightNavBgImage)) {
extraRules.push(`.layout-nav-bg { background-image: url('${theme.lightNavBgImage}'); }`); extraRules.push(`.layout-nav-bg { background-image: url('${theme.lightNavBgImage}'); }`);
} }
if (theme.darkPageBgImage) { if (theme.darkPageBgImage && isSafeCssUrl(theme.darkPageBgImage)) {
extraRules.push(`html.dark .layout-page-bg { background-image: url('${theme.darkPageBgImage}'); }`); extraRules.push(`html.dark .layout-page-bg { background-image: url('${theme.darkPageBgImage}'); }`);
} }
if (theme.darkNavBgImage) { if (theme.darkNavBgImage && isSafeCssUrl(theme.darkNavBgImage)) {
extraRules.push(`html.dark .layout-nav-bg { background-image: url('${theme.darkNavBgImage}'); }`); extraRules.push(`html.dark .layout-nav-bg { background-image: url('${theme.darkNavBgImage}'); }`);
} }
+10 -1
View File
@@ -1,11 +1,20 @@
import { marked } from 'marked'; import { marked } from 'marked';
// Disable raw HTML passthrough in markdown to prevent XSS on server-side rendering
marked.use({
renderer: {
html: () => '',
}
});
export function renderMarkdown(text: string): string { export function renderMarkdown(text: string): string {
if (!text) return ''; if (!text) return '';
const html = marked.parse(text, { async: false }) as string; const html = marked.parse(text, { async: false }) as string;
if (typeof window === 'undefined') return html; // DOMPurify provides a second layer of sanitization on the client
if (typeof window !== 'undefined') {
const DOMPurify = (globalThis as any).__dompurify; const DOMPurify = (globalThis as any).__dompurify;
if (DOMPurify) return DOMPurify.sanitize(html); if (DOMPurify) return DOMPurify.sanitize(html);
}
return html; return html;
} }
+9 -4
View File
@@ -1,5 +1,10 @@
import type { TenantThemeData } from '../../app.d.js'; import type { TenantThemeData } from '../../app.d.js';
/** Validates a theme background image URL is safe for CSS injection. */
function isSafeCssUrl(url: string): boolean {
return /^\/api\/branding\/[0-9a-f-]+\/[a-z-]+$/.test(url);
}
/** /**
* Client-side CSS variable mapping mirrors server-side theme-css.ts * Client-side CSS variable mapping mirrors server-side theme-css.ts
*/ */
@@ -77,16 +82,16 @@ export function persistThemeCSS(theme: TenantThemeData) {
// Handle background images // Handle background images
const extraRules: string[] = []; const extraRules: string[] = [];
if (theme.lightPageBgImage) { if (theme.lightPageBgImage && isSafeCssUrl(theme.lightPageBgImage)) {
extraRules.push(`.layout-page-bg { background-image: url('${theme.lightPageBgImage}'); }`); extraRules.push(`.layout-page-bg { background-image: url('${theme.lightPageBgImage}'); }`);
} }
if (theme.lightNavBgImage) { if (theme.lightNavBgImage && isSafeCssUrl(theme.lightNavBgImage)) {
extraRules.push(`.layout-nav-bg { background-image: url('${theme.lightNavBgImage}'); }`); extraRules.push(`.layout-nav-bg { background-image: url('${theme.lightNavBgImage}'); }`);
} }
if (theme.darkPageBgImage) { if (theme.darkPageBgImage && isSafeCssUrl(theme.darkPageBgImage)) {
extraRules.push(`html.dark .layout-page-bg { background-image: url('${theme.darkPageBgImage}'); }`); extraRules.push(`html.dark .layout-page-bg { background-image: url('${theme.darkPageBgImage}'); }`);
} }
if (theme.darkNavBgImage) { if (theme.darkNavBgImage && isSafeCssUrl(theme.darkNavBgImage)) {
extraRules.push(`html.dark .layout-nav-bg { background-image: url('${theme.darkNavBgImage}'); }`); extraRules.push(`html.dark .layout-nav-bg { background-image: url('${theme.darkNavBgImage}'); }`);
} }
+7 -2
View File
@@ -1,5 +1,10 @@
import type { TenantThemeData } from '../../app.d.js'; import type { TenantThemeData } from '../../app.d.js';
/** Validates a theme background image URL is safe for CSS injection. */
function isSafeCssUrl(url: string): boolean {
return /^\/api\/branding\/[0-9a-f-]+\/[a-z-]+$/.test(url);
}
const LIGHT_FIELD_MAP: Record<string, string> = { const LIGHT_FIELD_MAP: Record<string, string> = {
lightPageBg: '--color-page-bg', lightPageBg: '--color-page-bg',
lightNavBg: '--color-nav-bg', lightNavBg: '--color-nav-bg',
@@ -98,7 +103,7 @@ export function applyThemePreview(draft: TenantThemeData, mode: 'light' | 'dark'
// Handle bg images — set directly on target elements // Handle bg images — set directly on target elements
const pageBgImage = (draft as any)[pageBgImageField]; const pageBgImage = (draft as any)[pageBgImageField];
if (pageBgImage) { if (pageBgImage && isSafeCssUrl(pageBgImage)) {
const pageEl = document.querySelector('.layout-page-bg') as HTMLElement; const pageEl = document.querySelector('.layout-page-bg') as HTMLElement;
if (pageEl) { if (pageEl) {
pageEl.style.backgroundImage = `url('${pageBgImage}')`; pageEl.style.backgroundImage = `url('${pageBgImage}')`;
@@ -106,7 +111,7 @@ export function applyThemePreview(draft: TenantThemeData, mode: 'light' | 'dark'
} }
} }
const navBgImage = (draft as any)[navBgImageField]; const navBgImage = (draft as any)[navBgImageField];
if (navBgImage) { if (navBgImage && isSafeCssUrl(navBgImage)) {
const navEl = document.querySelector('.layout-nav-bg') as HTMLElement; const navEl = document.querySelector('.layout-nav-bg') as HTMLElement;
if (navEl) { if (navEl) {
navEl.style.backgroundImage = `url('${navBgImage}')`; navEl.style.backgroundImage = `url('${navBgImage}')`;
+11 -4
View File
@@ -8,12 +8,12 @@
let { data } = $props(); let { data } = $props();
// Profile // Profile
let name = $state(data.user.name); let name = $state('');
let email = $state(data.user.email); let email = $state('');
let savingProfile = $state(false); let savingProfile = $state(false);
// Avatar // Avatar
let avatarUrl = $state<string | null>(data.user.avatarUrl); let avatarUrl = $state<string | null>(null);
let cropFile = $state<File | null>(null); let cropFile = $state<File | null>(null);
let fileInput: HTMLInputElement; let fileInput: HTMLInputElement;
let uploadingAvatar = $state(false); let uploadingAvatar = $state(false);
@@ -25,9 +25,16 @@
let savingPassword = $state(false); let savingPassword = $state(false);
// Sessions // Sessions
let sessionCount = $state(data.sessionCount); let sessionCount = $state(0);
let revokingSessions = $state(false); let revokingSessions = $state(false);
$effect.pre(() => {
name = data.user.name;
email = data.user.email;
avatarUrl = data.user.avatarUrl;
sessionCount = data.sessionCount;
});
function onFileSelect(e: Event) { function onFileSelect(e: Event) {
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
const file = input.files?.[0]; const file = input.files?.[0];
+9 -4
View File
@@ -4,9 +4,14 @@
let { data } = $props(); let { data } = $props();
let users = $state(data.users.map((u: any) => ({ ...u }))); let users = $state<any[]>([]);
let tags = $state(data.tags.map((t: any) => ({ ...t }))); let tags = $state<any[]>([]);
let categories = $state(data.categories.map((c: any) => ({ ...c }))); let categories = $state<any[]>([]);
$effect.pre(() => {
users = data.users.map((u: any) => ({ ...u }));
tags = data.tags.map((t: any) => ({ ...t }));
categories = data.categories.map((c: any) => ({ ...c }));
});
// Tag form // Tag form
let newTagName = $state(''); let newTagName = $state('');
@@ -472,7 +477,7 @@
/> />
</div> </div>
<div> <div>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Events</label> <span class="block text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Events</span>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{#each availableEvents as event} {#each availableEvents as event}
<button <button
+13 -8
View File
@@ -14,10 +14,12 @@
let activeTab = $state<'light' | 'dark'>('light'); let activeTab = $state<'light' | 'dark'>('light');
let saving = $state(false); let saving = $state(false);
let logoText = $state(data.logoText ?? ''); let logoText = $state('');
let draft = $state<TenantThemeData>({});
// Draft theme — initialize from server data $effect.pre(() => {
let draft = $state<TenantThemeData>(data.theme ? { ...data.theme } : {}); logoText = data.logoText ?? '';
draft = data.theme ? { ...data.theme } : {};
});
const currentTheme = $derived(getTheme()); const currentTheme = $derived(getTheme());
@@ -210,7 +212,7 @@
onchange={(url) => { /* saved via image upload endpoint */ }} onchange={(url) => { /* saved via image upload endpoint */ }}
/> />
<div class="space-y-1.5"> <div class="space-y-1.5">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400">Logo text</label> <label for="logo-text" class="block text-xs font-medium text-gray-600 dark:text-gray-400">Logo text</label>
<input <input
type="text" type="text"
bind:value={logoText} bind:value={logoText}
@@ -287,10 +289,11 @@
/> />
</div> </div>
<div class="mt-4 space-y-2"> <div class="mt-4 space-y-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400"> <label for="column-opacity" class="block text-xs font-medium text-gray-600 dark:text-gray-400">
Opacity: {opacityPercent('columnOpacity')}% Opacity: {opacityPercent('columnOpacity')}%
</label> </label>
<input <input
id="column-opacity"
type="range" type="range"
min="0" min="0"
max="100" max="100"
@@ -327,10 +330,11 @@
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mt-4">
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400"> <label for="card-opacity" class="block text-xs font-medium text-gray-600 dark:text-gray-400">
Opacity: {opacityPercent('cardOpacity')}% Opacity: {opacityPercent('cardOpacity')}%
</label> </label>
<input <input
id="card-opacity"
type="range" type="range"
min="0" min="0"
max="100" max="100"
@@ -340,10 +344,11 @@
/> />
</div> </div>
<div class="space-y-2"> <div class="space-y-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-400"> <label for="card-radius" class="block text-xs font-medium text-gray-600 dark:text-gray-400">
Corner radius: {radiusPx('cardRadius')}px Corner radius: {radiusPx('cardRadius')}px
</label> </label>
<input <input
id="card-radius"
type="range" type="range"
min="0" min="0"
max="24" max="24"
+6 -2
View File
@@ -4,8 +4,12 @@
let { data } = $props(); let { data } = $props();
let tenants = $state(data.tenants.map((t: any) => ({ ...t }))); let tenants = $state<any[]>([]);
let stats = $state(data.stats); let stats = $state<any>({});
$effect.pre(() => {
tenants = data.tenants.map((t: any) => ({ ...t }));
stats = data.stats;
});
// Create tenant form // Create tenant form
let showCreate = $state(false); let showCreate = $state(false);
+14
View File
@@ -30,6 +30,20 @@ export const POST: RequestHandler = async ({ locals, request }) => {
const { url, events, secret } = body; const { url, events, secret } = body;
if (!url || typeof url !== 'string') throw error(400, 'URL is required'); if (!url || typeof url !== 'string') throw error(400, 'URL is required');
// Validate webhook URL format and block internal network targets
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw error(400, 'Invalid URL format');
}
if (parsed.protocol !== 'https:') throw error(400, 'Webhook URL must use HTTPS');
const hostname = parsed.hostname.toLowerCase();
if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname.endsWith('.local') || hostname === '0.0.0.0') {
throw error(400, 'Webhook URL must not target internal addresses');
}
if (!events || !Array.isArray(events) || events.length === 0) throw error(400, 'At least one event is required'); if (!events || !Array.isArray(events) || events.length === 0) throw error(400, 'At least one event is required');
const webhookSecret = secret && typeof secret === 'string' ? secret : crypto.randomBytes(32).toString('hex'); const webhookSecret = secret && typeof secret === 'string' ? secret : crypto.randomBytes(32).toString('hex');
@@ -5,7 +5,10 @@ import { join } from 'path';
const UPLOAD_ROOT = '/app/uploads'; const UPLOAD_ROOT = '/app/uploads';
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
if (!UUID_RE.test(params.userId)) throw error(400, 'Invalid user ID');
const filepath = join(UPLOAD_ROOT, 'avatars', `${params.userId}.webp`); const filepath = join(UPLOAD_ROOT, 'avatars', `${params.userId}.webp`);
let buffer: Buffer; let buffer: Buffer;
+5
View File
@@ -22,11 +22,16 @@ interface ImportCard {
}[]; }[];
} }
const MAX_BODY_SIZE = 5 * 1024 * 1024; // 5MB
export const POST: RequestHandler = async ({ request, locals }) => { export const POST: RequestHandler = async ({ request, locals }) => {
const tenant = locals.tenant; const tenant = locals.tenant;
if (!tenant) throw error(400, 'Unknown tenant'); if (!tenant) throw error(400, 'Unknown tenant');
requireAuth(locals.user); requireAuth(locals.user);
const contentLength = Number(request.headers.get('content-length') || 0);
if (contentLength > MAX_BODY_SIZE) throw error(400, 'Import payload too large (max 5MB)');
const body = await request.json(); const body = await request.json();
const { data: importData, format } = body; const { data: importData, format } = body;
@@ -5,8 +5,10 @@ import { join } from 'path';
const UPLOAD_ROOT = '/app/uploads'; const UPLOAD_ROOT = '/app/uploads';
const VALID_SLOTS = ['light-page-bg', 'light-nav-bg', 'dark-page-bg', 'dark-nav-bg']; const VALID_SLOTS = ['light-page-bg', 'light-nav-bg', 'dark-page-bg', 'dark-nav-bg'];
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
if (!UUID_RE.test(params.tenantId)) throw error(400, 'Invalid tenant ID');
if (!VALID_SLOTS.includes(params.slot)) { if (!VALID_SLOTS.includes(params.slot)) {
throw error(404, 'Not found'); throw error(404, 'Not found');
} }
+1 -1
View File
@@ -12,6 +12,6 @@ export const GET: RequestHandler = async () => {
// DB unreachable // DB unreachable
} }
const body = { status: db ? 'ok' : 'degraded', db, uptime: process.uptime() }; const body = { status: db ? 'ok' : 'degraded', db };
return json(body, { status: db ? 200 : 503 }); return json(body, { status: db ? 200 : 503 });
}; };
+1 -1
View File
@@ -18,7 +18,7 @@
let filterCategoryId = $state(''); let filterCategoryId = $state('');
let showArchived = $state($page.url.searchParams.get('archived') === 'true'); let showArchived = $state($page.url.searchParams.get('archived') === 'true');
let importing = $state(false); let importing = $state(false);
let fileInput: HTMLInputElement; let fileInput = $state<HTMLInputElement>(undefined!);
async function handleImport(e: Event) { async function handleImport(e: Event) {
const input = e.target as HTMLInputElement; const input = e.target as HTMLInputElement;
+8 -4
View File
@@ -19,12 +19,13 @@
let { data } = $props(); let { data } = $props();
// Local mutable state derived from server data // Local mutable state derived from server data
let columns = $state( let columns = $state<any[]>([]);
data.board.columns.map((col: any) => ({ $effect.pre(() => {
columns = data.board.columns.map((col: any) => ({
...col, ...col,
cards: col.cards.map((card: any) => ({ ...card, id: card.id })) cards: col.cards.map((card: any) => ({ ...card, id: card.id }))
})) }));
); });
let addingColumnTitle = $state(''); let addingColumnTitle = $state('');
let showAddColumn = $state(false); let showAddColumn = $state(false);
@@ -616,6 +617,7 @@
onsubmit={(e) => { e.preventDefault(); renameColumn(column.id); }} onsubmit={(e) => { e.preventDefault(); renameColumn(column.id); }}
class="flex-1 flex gap-1" class="flex-1 flex gap-1"
> >
<!-- svelte-ignore a11y_autofocus -->
<input <input
bind:value={editingColumnTitle} bind:value={editingColumnTitle}
class="flex-1 rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-2 py-1 text-sm font-semibold" class="flex-1 rounded border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-2 py-1 text-sm font-semibold"
@@ -802,6 +804,7 @@
<form <form
onsubmit={(e) => { e.preventDefault(); addCard(column.id); }} onsubmit={(e) => { e.preventDefault(); addCard(column.id); }}
> >
<!-- svelte-ignore a11y_autofocus -->
<textarea <textarea
bind:value={newCardTitle} bind:value={newCardTitle}
placeholder="Enter a title for this card..." placeholder="Enter a title for this card..."
@@ -854,6 +857,7 @@
{#if showAddColumn} {#if showAddColumn}
<div class="rounded-xl p-3" style="background: var(--color-column-bg);"> <div class="rounded-xl p-3" style="background: var(--color-column-bg);">
<form onsubmit={(e) => { e.preventDefault(); addColumn(); }}> <form onsubmit={(e) => { e.preventDefault(); addColumn(); }}>
<!-- svelte-ignore a11y_autofocus -->
<input <input
bind:value={addingColumnTitle} bind:value={addingColumnTitle}
placeholder="Enter list title..." placeholder="Enter list title..."
@@ -5,8 +5,12 @@
let { data } = $props(); let { data } = $props();
let cards = $state(data.cards); let cards = $state<any[]>([]);
let dependencies = $state(data.dependencies); let dependencies = $state<any[]>([]);
$effect.pre(() => {
cards = data.cards;
dependencies = data.dependencies;
});
onMount(() => { onMount(() => {
joinBoard(data.board.id); joinBoard(data.board.id);
@@ -3,7 +3,10 @@
let { data } = $props(); let { data } = $props();
let members = $state(data.board.members.map((m: any) => ({ ...m }))); let members = $state<any[]>([]);
$effect.pre(() => {
members = data.board.members.map((m: any) => ({ ...m }));
});
let addEmail = $state(''); let addEmail = $state('');
let addRole = $state('EDITOR'); let addRole = $state('EDITOR');
let addError = $state(''); let addError = $state('');