fix: address all code review findings

- Extract shared permission logic into boardPermissions.ts utility
- Fix DnD drag revert: add dirty flag to prevent  overwrite
- Wrap OAuth group sync in Prisma transaction (N+1 fix)
- Add empty widgetIds validation in widget reorder API
- Add invalidateAll() after guest toggle PATCH
- Replace console.error with user-visible error banners
- Extract WidgetCreationForm component (DraggableSection was 448 lines)
- Remove unused boardId prop from DraggableSection
- Always include OAuth state parameter + validate in callback
- Clean up copyLink timer on component destroy
- Add type-specific widget config validation in addWidget action
This commit is contained in:
2026-03-25 00:03:32 +03:00
parent 5a6002be76
commit cba160ecb8
15 changed files with 588 additions and 447 deletions
@@ -37,8 +37,8 @@ export const PUT: RequestHandler = async (event) => {
}
const { widgetIds } = body as { widgetIds?: string[] };
if (!Array.isArray(widgetIds)) {
return json(error('widgetIds must be an array of strings'), { status: 400 });
if (!Array.isArray(widgetIds) || widgetIds.length === 0) {
return json(error('widgetIds must be a non-empty array of strings'), { status: 400 });
}
if (!widgetIds.every((wid) => typeof wid === 'string')) {
+8 -3
View File
@@ -14,18 +14,23 @@ export const GET: RequestHandler = async ({ cookies, url }) => {
const appUrl = process.env.APP_URL || url.origin;
const redirectUri = process.env.OAUTH_REDIRECT_URI || `${appUrl}/auth/oauth/callback`;
// Generate PKCE values
// Generate PKCE values and state parameter
const codeVerifier = oauthService.generateCodeVerifier();
const codeChallenge = await oauthService.calculateCodeChallenge(codeVerifier);
const state = oauthService.generateState();
// Store code_verifier in HTTP-only cookie for the callback
// Store code_verifier and state in HTTP-only cookies for the callback
cookies.set('oauth_code_verifier', codeVerifier, {
...COOKIE_BASE,
maxAge: 600 // 10 minutes — enough for the auth flow
});
cookies.set('oauth_state', state, {
...COOKIE_BASE,
maxAge: 600
});
// Build authorization URL and redirect
const authUrl = await oauthService.generateAuthUrl(redirectUri, codeChallenge);
const authUrl = await oauthService.generateAuthUrl(redirectUri, codeChallenge, state);
throw redirect(302, authUrl);
} catch (err) {
+15 -3
View File
@@ -26,17 +26,29 @@ export const GET: RequestHandler = async ({ url, cookies }) => {
throw new Error('No authorization code received from OAuth provider');
}
// Retrieve the code_verifier from the cookie
// Retrieve the code_verifier and state from cookies
const codeVerifier = cookies.get('oauth_code_verifier');
if (!codeVerifier) {
throw new Error('OAuth session expired. Please try logging in again.');
}
// Clear the code_verifier cookie
const expectedState = cookies.get('oauth_state');
if (!expectedState) {
throw new Error('OAuth session expired. Please try logging in again.');
}
// Validate the state parameter matches to prevent CSRF
const returnedState = url.searchParams.get('state');
if (returnedState !== expectedState) {
throw new Error('OAuth state mismatch. Possible CSRF attack. Please try logging in again.');
}
// Clear the OAuth cookies
cookies.delete('oauth_code_verifier', { path: '/' });
cookies.delete('oauth_state', { path: '/' });
// Exchange the authorization code for tokens and get user info
const userInfo = await oauthService.handleCallback(url, codeVerifier);
const userInfo = await oauthService.handleCallback(url, codeVerifier, expectedState);
// Find or create local user from OAuth info
const user = await userService.findOrCreateByOAuth({
+15 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { t } from 'svelte-i18n';
import type { PageData } from './$types.js';
import { invalidateAll } from '$app/navigation';
import Board from '$lib/components/board/Board.svelte';
import BoardHeader from '$lib/components/board/BoardHeader.svelte';
import BoardShareDialog from '$lib/components/board/BoardShareDialog.svelte';
@@ -8,16 +9,23 @@
let { data }: { data: PageData } = $props();
let showShareDialog = $state(false);
let guestToggleError = $state('');
async function handleGuestToggle(value: boolean) {
guestToggleError = '';
try {
await fetch(`/api/boards/${data.board.id}`, {
const res = await fetch(`/api/boards/${data.board.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isGuestAccessible: value })
});
} catch (err) {
console.error('Failed to update guest access:', err);
if (res.ok) {
await invalidateAll();
} else {
guestToggleError = 'Failed to update guest access';
}
} catch {
guestToggleError = 'Network error updating guest access';
}
}
</script>
@@ -37,6 +45,10 @@
onShare={() => { showShareDialog = true; }}
/>
{#if guestToggleError}
<p class="mb-2 text-sm text-destructive">{guestToggleError}</p>
{/if}
<Board sections={data.board.sections} allApps={data.allApps} />
</div>
</div>
@@ -6,12 +6,17 @@ import * as permissionService from '$lib/server/services/permissionService.js';
import * as userService from '$lib/server/services/userService.js';
import * as groupService from '$lib/server/services/groupService.js';
import { requireAuth } from '$lib/server/middleware/authenticate.js';
import { EntityType, PermissionLevel, UserRole } from '$lib/utils/constants.js';
import { EntityType, PermissionLevel, UserRole, WidgetType } from '$lib/utils/constants.js';
import {
updateBoardSchema,
createSectionSchema,
updateSectionSchema,
createWidgetSchema
createWidgetSchema,
appWidgetConfigSchema,
bookmarkWidgetConfigSchema,
noteWidgetConfigSchema,
embedWidgetConfigSchema,
statusWidgetConfigSchema
} from '$lib/utils/validators.js';
export const load: PageServerLoad = async (event) => {
@@ -214,6 +219,35 @@ export const actions: Actions = {
return { success: false, error: parsed.error.errors.map((e) => e.message).join(', ') };
}
// Validate config JSON against the type-specific schema
if (config && config !== '{}') {
let parsedConfig: unknown;
try {
parsedConfig = JSON.parse(config);
} catch {
return { success: false, error: 'Invalid config JSON' };
}
const configSchemaMap = {
[WidgetType.APP]: appWidgetConfigSchema,
[WidgetType.BOOKMARK]: bookmarkWidgetConfigSchema,
[WidgetType.NOTE]: noteWidgetConfigSchema,
[WidgetType.EMBED]: embedWidgetConfigSchema,
[WidgetType.STATUS]: statusWidgetConfigSchema
} as const;
const configSchema = configSchemaMap[type as keyof typeof configSchemaMap];
if (configSchema) {
const configResult = configSchema.safeParse(parsedConfig);
if (!configResult.success) {
return {
success: false,
error: configResult.error.errors.map((e) => e.message).join(', ')
};
}
}
}
try {
await boardService.createWidget(parsed.data);
return { success: true };
+13 -3
View File
@@ -11,6 +11,7 @@
let showAddSection = $state(false);
let addWidgetSectionId = $state<string | null>(null);
let errorMessage = $state('');
function handleToggleAddWidget(sectionId: string) {
addWidgetSectionId = addWidgetSectionId === sectionId ? null : sectionId;
@@ -27,7 +28,7 @@
});
await invalidateAll();
} catch (err) {
console.error('Failed to delete section:', err);
errorMessage = err instanceof Error ? err.message : 'Failed to delete section';
}
}
@@ -80,7 +81,7 @@
addWidgetSectionId = null;
await invalidateAll();
} catch (err) {
console.error('Failed to add widget:', err);
errorMessage = err instanceof Error ? err.message : 'Failed to add widget';
}
}
@@ -95,7 +96,7 @@
});
await invalidateAll();
} catch (err) {
console.error('Failed to delete widget:', err);
errorMessage = err instanceof Error ? err.message : 'Failed to delete widget';
}
}
</script>
@@ -106,6 +107,15 @@
<div class="p-6">
<div class="mx-auto max-w-4xl">
{#if errorMessage}
<div class="mb-4 rounded-lg border border-destructive bg-destructive/10 p-3">
<p class="text-sm text-destructive">{errorMessage}</p>
<button type="button" onclick={() => { errorMessage = ''; }} class="mt-1 text-xs text-destructive underline">
{$t('common.dismiss') ?? 'Dismiss'}
</button>
</div>
{/if}
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-foreground">{$t('board.edit_board')}</h1>
<a