diff --git a/src/lib/components/board/BoardAccessControl.svelte b/src/lib/components/board/BoardAccessControl.svelte index 29fe182..7cc5135 100644 --- a/src/lib/components/board/BoardAccessControl.svelte +++ b/src/lib/components/board/BoardAccessControl.svelte @@ -1,21 +1,14 @@ diff --git a/src/lib/components/board/DraggableBoard.svelte b/src/lib/components/board/DraggableBoard.svelte index 8bae3f1..1713e82 100644 --- a/src/lib/components/board/DraggableBoard.svelte +++ b/src/lib/components/board/DraggableBoard.svelte @@ -53,19 +53,25 @@ }: Props = $props(); let sections = $state([...initialSections]); + let dirty = $state(false); + let errorMessage = $state(''); - // Keep local state in sync when parent data changes + // Keep local state in sync when parent data changes (skip during drag) $effect(() => { - sections = [...initialSections]; + if (!dirty) { + sections = [...initialSections]; + } }); const flipDurationMs = 200; function handleConsider(e: CustomEvent<{ items: SectionData[] }>) { + dirty = true; sections = e.detail.items; } async function handleFinalize(e: CustomEvent<{ items: SectionData[] }>) { + dirty = true; sections = e.detail.items; const sectionIds = sections.map((s) => s.id); @@ -76,12 +82,15 @@ body: JSON.stringify({ sectionIds }) }); } catch (err) { - console.error('Failed to persist section reorder:', err); + errorMessage = err instanceof Error ? err.message : 'Failed to persist section reorder'; + } finally { + dirty = false; } } async function handleWidgetsUpdate(sectionId: string, widgets: WidgetData[]) { // Update local state + dirty = true; sections = sections.map((s) => (s.id === sectionId ? { ...s, widgets } : s)); const widgetIds = widgets.map((w) => w.id); @@ -93,11 +102,17 @@ body: JSON.stringify({ widgetIds }) }); } catch (err) { - console.error('Failed to persist widget reorder:', err); + errorMessage = err instanceof Error ? err.message : 'Failed to persist widget reorder'; + } finally { + dirty = false; } } +{#if errorMessage} + {errorMessage} +{/if} + {#if sections.length === 0} {$t('board.no_sections')} @@ -113,7 +128,6 @@ ; onWidgetsUpdate: (sectionId: string, widgets: WidgetData[]) => void; addWidgetSectionId: string | null; @@ -44,7 +44,6 @@ let { section, - boardId: _boardId = '', apps, onWidgetsUpdate, addWidgetSectionId, @@ -54,108 +53,28 @@ onDeleteWidget }: Props = $props(); - // boardId reserved for future per-section API calls - void _boardId; - let widgets = $state([...section.widgets]); + let dirty = $state(false); - // Keep local state in sync when parent data changes + // Keep local state in sync when parent data changes (skip during drag) $effect(() => { - widgets = [...section.widgets]; + if (!dirty) { + widgets = [...section.widgets]; + } }); const flipDurationMs = 200; function handleConsider(e: CustomEvent<{ items: WidgetData[] }>) { + dirty = true; widgets = e.detail.items; } function handleFinalize(e: CustomEvent<{ items: WidgetData[] }>) { + dirty = true; widgets = e.detail.items; onWidgetsUpdate(section.id, widgets); - } - - // Widget form state - let selectedWidgetType = $state('app'); - let selectedAppId = $state(''); - - // Bookmark fields - let bookmarkUrl = $state(''); - let bookmarkLabel = $state(''); - let bookmarkIcon = $state(''); - let bookmarkDescription = $state(''); - - // Note fields - let noteContent = $state(''); - let noteFormat = $state<'markdown' | 'text'>('markdown'); - - // Embed fields - let embedUrl = $state(''); - let embedHeight = $state(300); - - // Status fields - let statusLabel = $state(''); - let statusAppIds = $state([]); - - function resetForm() { - selectedWidgetType = 'app'; - selectedAppId = ''; - bookmarkUrl = ''; - bookmarkLabel = ''; - bookmarkIcon = ''; - bookmarkDescription = ''; - noteContent = ''; - noteFormat = 'markdown'; - embedUrl = ''; - embedHeight = 300; - statusLabel = ''; - statusAppIds = []; - } - - function handleSubmitWidget() { - let widgetData: Record = { type: selectedWidgetType }; - - switch (selectedWidgetType) { - case 'app': - if (!selectedAppId) return; - widgetData.appId = selectedAppId; - break; - case 'bookmark': - if (!bookmarkUrl || !bookmarkLabel) return; - widgetData.url = bookmarkUrl; - widgetData.label = bookmarkLabel; - if (bookmarkIcon) widgetData.icon = bookmarkIcon; - if (bookmarkDescription) widgetData.description = bookmarkDescription; - break; - case 'note': - if (!noteContent) return; - widgetData.content = noteContent; - widgetData.format = noteFormat; - break; - case 'embed': - if (!embedUrl) return; - widgetData.url = embedUrl; - widgetData.height = embedHeight; - break; - case 'status': - if (statusAppIds.length === 0) return; - widgetData.appIds = statusAppIds; - if (statusLabel) widgetData.label = statusLabel; - break; - default: - return; - } - - onAddWidget(section.id, JSON.stringify(widgetData)); - resetForm(); - } - - function toggleStatusApp(appId: string) { - if (statusAppIds.includes(appId)) { - statusAppIds = statusAppIds.filter((id) => id !== appId); - } else { - statusAppIds = [...statusAppIds, appId]; - } + dirty = false; } function getWidgetLabel(widget: WidgetData): string { @@ -227,181 +146,11 @@ {#if addWidgetSectionId === section.id} - - - - - Widget Type - - - App - Bookmark - Note - Embed - Status - - - - - {#if selectedWidgetType === 'app'} - - - {$t('widget.select_app')} - - - {$t('widget.choose_app')} - {#each apps as app (app.id)} - {app.name} - {/each} - - - {:else if selectedWidgetType === 'bookmark'} - - - URL - - - - Label - - - - Icon (optional) - - - - Description (optional) - - - - {:else if selectedWidgetType === 'note'} - - - Format - - Markdown - Plain Text - - - - Content - - - - {:else if selectedWidgetType === 'embed'} - - - URL - - - - Height (px) - - - - {:else if selectedWidgetType === 'status'} - - - Label (optional) - - - - Select Apps - - {#each apps as app (app.id)} - - toggleStatusApp(app.id)} - class="h-4 w-4 rounded border-input accent-primary" - /> - {app.name} - - {/each} - - {#if statusAppIds.length > 0} - {statusAppIds.length} app(s) selected - {/if} - - - {/if} - - - - {$t('common.add')} - - - + {/if} diff --git a/src/lib/components/widget/WidgetCreationForm.svelte b/src/lib/components/widget/WidgetCreationForm.svelte new file mode 100644 index 0000000..532c402 --- /dev/null +++ b/src/lib/components/widget/WidgetCreationForm.svelte @@ -0,0 +1,270 @@ + + + + + + + Widget Type + + + App + Bookmark + Note + Embed + Status + + + + + {#if selectedWidgetType === 'app'} + + + {$t('widget.select_app')} + + + {$t('widget.choose_app')} + {#each apps as app (app.id)} + {app.name} + {/each} + + + {:else if selectedWidgetType === 'bookmark'} + + + URL + + + + Label + + + + Icon (optional) + + + + Description (optional) + + + + {:else if selectedWidgetType === 'note'} + + + Format + + Markdown + Plain Text + + + + Content + + + + {:else if selectedWidgetType === 'embed'} + + + URL + + + + Height (px) + + + + {:else if selectedWidgetType === 'status'} + + + Label (optional) + + + + Select Apps + + {#each apps as app (app.id)} + + toggleStatusApp(app.id)} + class="h-4 w-4 rounded border-input accent-primary" + /> + {app.name} + + {/each} + + {#if statusAppIds.length > 0} + {statusAppIds.length} app(s) selected + {/if} + + + {/if} + + + + {$t('common.add')} + + + diff --git a/src/lib/server/services/__tests__/oauthService.test.ts b/src/lib/server/services/__tests__/oauthService.test.ts index 2a17a4b..c56d504 100644 --- a/src/lib/server/services/__tests__/oauthService.test.ts +++ b/src/lib/server/services/__tests__/oauthService.test.ts @@ -25,6 +25,7 @@ import { prisma } from '../../prisma.js'; import { invalidateOAuthCache, generateCodeVerifier, + generateState, calculateCodeChallenge, generateAuthUrl, handleCallback, @@ -69,6 +70,14 @@ describe('oauthService', () => { }); }); + describe('generateState', () => { + it('returns a random state string', () => { + const state = generateState(); + expect(state).toBe('mock-state-123'); + expect(mockClient.randomState).toHaveBeenCalledOnce(); + }); + }); + describe('calculateCodeChallenge', () => { it('returns a PKCE code challenge', async () => { const challenge = await calculateCodeChallenge('my-verifier'); @@ -86,7 +95,7 @@ describe('oauthService', () => { new URL('https://auth.example.com/authorize?code_challenge=abc') ); - const url = await generateAuthUrl('https://app.example.com/callback', 'test-challenge'); + const url = await generateAuthUrl('https://app.example.com/callback', 'test-challenge', 'test-state'); expect(url).toBe('https://auth.example.com/authorize?code_challenge=abc'); expect(mockClient.buildAuthorizationUrl).toHaveBeenCalledWith( @@ -95,7 +104,8 @@ describe('oauthService', () => { redirect_uri: 'https://app.example.com/callback', scope: 'openid profile email', code_challenge: 'test-challenge', - code_challenge_method: 'S256' + code_challenge_method: 'S256', + state: 'test-state' }) ); }); @@ -111,7 +121,7 @@ describe('oauthService', () => { delete process.env.OAUTH_DISCOVERY_URL; await expect( - generateAuthUrl('https://app.example.com/callback', 'challenge') + generateAuthUrl('https://app.example.com/callback', 'challenge', 'state') ).rejects.toThrow('OAuth is not configured'); // Restore @@ -120,25 +130,20 @@ describe('oauthService', () => { process.env.OAUTH_DISCOVERY_URL = origDiscovery; }); - it('adds state when provider does not support PKCE', async () => { + it('always includes the state parameter', async () => { setupOAuthSettings(); - const mockConfig = { - serverMetadata: () => ({ - issuer: 'https://auth.example.com', - supportsPKCE: () => false - }) - }; + const mockConfig = createMockOIDCConfig(); mockClient.discovery.mockResolvedValue(mockConfig); mockClient.buildAuthorizationUrl.mockReturnValue( new URL('https://auth.example.com/authorize') ); - await generateAuthUrl('https://app.example.com/callback', 'test-challenge'); + await generateAuthUrl('https://app.example.com/callback', 'test-challenge', 'custom-state'); expect(mockClient.buildAuthorizationUrl).toHaveBeenCalledWith( mockConfig, expect.objectContaining({ - state: 'mock-state-123' + state: 'custom-state' }) ); }); @@ -163,8 +168,9 @@ describe('oauthService', () => { }); const result = await handleCallback( - new URL('https://app.example.com/callback?code=abc'), - 'test-verifier' + new URL('https://app.example.com/callback?code=abc&state=test-state'), + 'test-verifier', + 'test-state' ); expect(result).toEqual({ @@ -188,8 +194,9 @@ describe('oauthService', () => { await expect( handleCallback( - new URL('https://app.example.com/callback?code=abc'), - 'test-verifier' + new URL('https://app.example.com/callback?code=abc&state=test-state'), + 'test-verifier', + 'test-state' ) ).rejects.toThrow('subject claim'); }); @@ -209,8 +216,9 @@ describe('oauthService', () => { await expect( handleCallback( - new URL('https://app.example.com/callback?code=abc'), - 'test-verifier' + new URL('https://app.example.com/callback?code=abc&state=test-state'), + 'test-verifier', + 'test-state' ) ).rejects.toThrow('email'); }); diff --git a/src/lib/server/services/oauthService.ts b/src/lib/server/services/oauthService.ts index ddac94f..27d3b69 100644 --- a/src/lib/server/services/oauthService.ts +++ b/src/lib/server/services/oauthService.ts @@ -96,6 +96,13 @@ export function generateCodeVerifier(): string { return client.randomPKCECodeVerifier(); } +/** + * Generates a cryptographically random state parameter. + */ +export function generateState(): string { + return client.randomState(); +} + /** * Calculates the PKCE code_challenge from a code_verifier. */ @@ -105,10 +112,12 @@ export async function calculateCodeChallenge(codeVerifier: string): Promise { const config = await getOIDCConfig(); @@ -116,14 +125,10 @@ export async function generateAuthUrl( redirect_uri: redirectUri, scope: 'openid profile email', code_challenge: codeChallenge, - code_challenge_method: 'S256' + code_challenge_method: 'S256', + state }; - // Add state if the server might not support PKCE - if (!config.serverMetadata().supportsPKCE()) { - parameters.state = client.randomState(); - } - const url = client.buildAuthorizationUrl(config, parameters); return url.href; } @@ -133,12 +138,14 @@ export async function generateAuthUrl( */ export async function handleCallback( callbackUrl: URL, - codeVerifier: string + codeVerifier: string, + expectedState: string ): Promise { const config = await getOIDCConfig(); const tokens = await client.authorizationCodeGrant(config, callbackUrl, { - pkceCodeVerifier: codeVerifier + pkceCodeVerifier: codeVerifier, + expectedState }); // Try to get user info from the userinfo endpoint diff --git a/src/lib/server/services/userService.ts b/src/lib/server/services/userService.ts index 7946667..2416347 100644 --- a/src/lib/server/services/userService.ts +++ b/src/lib/server/services/userService.ts @@ -184,14 +184,16 @@ async function syncOAuthGroups(userId: string, oauthGroupNames: readonly string[ return; } - // Upsert memberships (idempotent — won't fail if already a member) - for (const group of matchingGroups) { - await prisma.userGroup.upsert({ - where: { - userId_groupId: { userId, groupId: group.id } - }, - update: {}, - create: { userId, groupId: group.id } - }); - } + // Upsert memberships in a single transaction (idempotent — won't fail if already a member) + await prisma.$transaction( + matchingGroups.map((group) => + prisma.userGroup.upsert({ + where: { + userId_groupId: { userId, groupId: group.id } + }, + update: {}, + create: { userId, groupId: group.id } + }) + ) + ); } diff --git a/src/lib/utils/boardPermissions.ts b/src/lib/utils/boardPermissions.ts new file mode 100644 index 0000000..0d418a0 --- /dev/null +++ b/src/lib/utils/boardPermissions.ts @@ -0,0 +1,86 @@ +import { TargetType } from './constants.js'; + +export interface PermissionRecord { + id: string; + entityType: string; + entityId: string; + targetType: string; + targetId: string; + level: string; + createdAt: string; +} + +export interface SelectOption { + id: string; + name: string; +} + +interface ApiResponse { + success: boolean; + data?: T; + error?: string; +} + +/** + * Fetches the permission records for a board. + */ +export async function loadBoardPermissions(boardId: string): Promise { + const res = await fetch(`/api/boards/${boardId}/permissions`); + const json: ApiResponse = await res.json(); + if (json.success && json.data) { + return json.data; + } + throw new Error(json.error ?? 'Failed to load permissions'); +} + +/** + * Grants a permission on a board to a user or group. + */ +export async function grantBoardPermission( + boardId: string, + targetType: string, + targetId: string, + level: string +): Promise { + const res = await fetch(`/api/boards/${boardId}/permissions`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetType, targetId, level }) + }); + const json: ApiResponse = await res.json(); + if (!json.success) { + throw new Error(json.error ?? 'Failed to grant permission'); + } +} + +/** + * Revokes a permission on a board for a user or group. + */ +export async function revokeBoardPermission( + boardId: string, + targetType: string, + targetId: string +): Promise { + const res = await fetch(`/api/boards/${boardId}/permissions`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ targetType, targetId }) + }); + const json: ApiResponse = await res.json(); + if (!json.success) { + throw new Error(json.error ?? 'Failed to revoke permission'); + } +} + +/** + * Resolves a target (user or group) ID to a display name. + */ +export function getTargetName( + targetType: string, + targetId: string, + users: SelectOption[], + groups: SelectOption[] +): string { + const list = targetType === TargetType.USER ? users : groups; + return list.find((item) => item.id === targetId)?.name ?? targetId; +} diff --git a/src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts b/src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts index c44568f..06a7be6 100644 --- a/src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts +++ b/src/routes/api/boards/[id]/sections/[sid]/reorder/+server.ts @@ -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')) { diff --git a/src/routes/auth/oauth/authorize/+server.ts b/src/routes/auth/oauth/authorize/+server.ts index 22071a6..8e34972 100644 --- a/src/routes/auth/oauth/authorize/+server.ts +++ b/src/routes/auth/oauth/authorize/+server.ts @@ -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) { diff --git a/src/routes/auth/oauth/callback/+server.ts b/src/routes/auth/oauth/callback/+server.ts index 489e9de..14212ae 100644 --- a/src/routes/auth/oauth/callback/+server.ts +++ b/src/routes/auth/oauth/callback/+server.ts @@ -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({ diff --git a/src/routes/boards/[boardId]/+page.svelte b/src/routes/boards/[boardId]/+page.svelte index 43849a1..3f7a62e 100644 --- a/src/routes/boards/[boardId]/+page.svelte +++ b/src/routes/boards/[boardId]/+page.svelte @@ -1,6 +1,7 @@ @@ -37,6 +45,10 @@ onShare={() => { showShareDialog = true; }} /> + {#if guestToggleError} + {guestToggleError} + {/if} + diff --git a/src/routes/boards/[boardId]/edit/+page.server.ts b/src/routes/boards/[boardId]/edit/+page.server.ts index 0dd503e..5044263 100644 --- a/src/routes/boards/[boardId]/edit/+page.server.ts +++ b/src/routes/boards/[boardId]/edit/+page.server.ts @@ -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 }; diff --git a/src/routes/boards/[boardId]/edit/+page.svelte b/src/routes/boards/[boardId]/edit/+page.svelte index 80a89d8..5ad040c 100644 --- a/src/routes/boards/[boardId]/edit/+page.svelte +++ b/src/routes/boards/[boardId]/edit/+page.svelte @@ -11,6 +11,7 @@ let showAddSection = $state(false); let addWidgetSectionId = $state(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'; } } @@ -106,6 +107,15 @@ + {#if errorMessage} + + {errorMessage} + { errorMessage = ''; }} class="mt-1 text-xs text-destructive underline"> + {$t('common.dismiss') ?? 'Dismiss'} + + + {/if} + {$t('board.edit_board')}
{errorMessage}
{$t('board.no_sections')}
{statusAppIds.length} app(s) selected
{guestToggleError}