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:
Generated
+9
-6
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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..."
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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..."
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}'); }`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}'); }`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}')`;
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
|||||||
Reference in New Issue
Block a user