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:
@@ -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')) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user