From 1fb9ea9125ebcc10931db31859916800bbc67c58 Mon Sep 17 00:00:00 2001 From: mpJunot Date: Wed, 11 Feb 2026 10:52:22 +0100 Subject: [PATCH] fix uploads, backgrounds and card completion --- .../src/modules/upload/upload.controller.ts | 4 + frontend/app/boards/[id]/cardEventHandlers.ts | 18 +++- .../BoardHeader/ChangeBackgroundDialog.tsx | 25 ++++-- frontend/app/boards/[id]/page.tsx | 8 +- frontend/app/cards/CardsTable.tsx | 2 +- .../app/settings/components/AvatarPicker.tsx | 44 ++++------ frontend/components/CardModal.tsx | 8 +- .../CardModal/CardModalBackgroundPicker.tsx | 10 ++- frontend/e2e/playwright-report/index.html | 85 ------------------- frontend/lib/actions/users.ts | 81 +++++++++++------- frontend/next.config.ts | 28 +++--- 11 files changed, 143 insertions(+), 170 deletions(-) delete mode 100644 frontend/e2e/playwright-report/index.html diff --git a/backend/src/modules/upload/upload.controller.ts b/backend/src/modules/upload/upload.controller.ts index 6c0ccaa..d7e88fc 100644 --- a/backend/src/modules/upload/upload.controller.ts +++ b/backend/src/modules/upload/upload.controller.ts @@ -2,6 +2,7 @@ import { Controller, Post, Req, + UseGuards, UseInterceptors, UploadedFile, BadRequestException, @@ -12,6 +13,7 @@ import { memoryStorage } from 'multer'; import { join } from 'path'; import { existsSync, mkdirSync, writeFileSync } from 'fs'; import { Request } from 'express'; +import { GqlAuthGuard } from '../../common/guards/gql-auth.guard'; import { StorageService } from './storage.service'; const AVATARS_DIR = 'uploads/avatars'; @@ -49,8 +51,10 @@ export function createImageFileFilter(): ( /** * Upload controller: saves files to Google Cloud Storage (or disk when GCS not configured) * and returns public URLs. Used for avatar, board/card background images. + * Protected by JWT (Bearer or cookie auth_token); same auth as GraphQL. */ @Controller('api/upload') +@UseGuards(GqlAuthGuard) export class UploadController { constructor(private readonly storage: StorageService) {} diff --git a/frontend/app/boards/[id]/cardEventHandlers.ts b/frontend/app/boards/[id]/cardEventHandlers.ts index 63e281c..ba4436c 100644 --- a/frontend/app/boards/[id]/cardEventHandlers.ts +++ b/frontend/app/boards/[id]/cardEventHandlers.ts @@ -7,6 +7,7 @@ import { reorderCards, deleteCard, archiveCard, + updateCard, } from '@/lib/actions/cards'; import { List, Card } from './types'; import { boardQueryKey } from './queries'; @@ -237,7 +238,7 @@ export function createCardEventHandlers( ); } - function handleCardCompletedUpdate( + async function handleCardCompletedUpdate( e?: DetailEvent<{ cardId: string; completed: boolean }> ) { const detail = e?.detail; @@ -249,6 +250,21 @@ export function createCardEventHandlers( cards: (lst.cards || []).map((c) => (c.id === cardId ? { ...c, completed } : c)), })) ); + try { + await updateCard({ id: cardId, completed }); + invalidateBoard(); + invalidateActivity(); + } catch (err) { + setLists((prevLists) => + prevLists.map((lst) => ({ + ...lst, + cards: (lst.cards || []).map((c) => + c.id === cardId ? { ...c, completed: !completed } : c + ), + })) + ); + handleAsyncError(err, 'update completed'); + } } async function handleCardDelete(e?: DetailEvent<{ cardId: string }>) { diff --git a/frontend/app/boards/[id]/components/BoardHeader/ChangeBackgroundDialog.tsx b/frontend/app/boards/[id]/components/BoardHeader/ChangeBackgroundDialog.tsx index a4ab622..5cfb1ce 100644 --- a/frontend/app/boards/[id]/components/BoardHeader/ChangeBackgroundDialog.tsx +++ b/frontend/app/boards/[id]/components/BoardHeader/ChangeBackgroundDialog.tsx @@ -37,7 +37,13 @@ function getSafeImageSrc(url: string | undefined): string | undefined { return undefined; } -function BackgroundPreviewImg({ src }: { src: string }) { +function BackgroundPreviewImg({ + src, + onError, +}: { + src: string; + onError?: () => void; +}) { return ( ); } @@ -64,14 +71,16 @@ export function ChangeBackgroundDialog({ }: ChangeBackgroundDialogProps) { const [backgroundInput, setBackgroundInput] = useState(''); const [localBackground, setLocalBackground] = useState( - undefined + undefined, ); + const [previewError, setPreviewError] = useState(false); const [updating, setUpdating] = useState(false); useEffect(() => { if (board) { setBackgroundInput(board.background || ''); setLocalBackground(board.background || undefined); + setPreviewError(false); } }, [board, open]); @@ -89,7 +98,9 @@ export function ChangeBackgroundDialog({ const { url } = await uploadBackground(file); await saveBoardBackground(url); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Failed to upload image'); + toast.error( + err instanceof Error ? err.message : 'Failed to upload image', + ); } finally { setUpdating(false); e.target.value = ''; @@ -141,6 +152,7 @@ export function ChangeBackgroundDialog({ }; const safePreviewUrl = getSafeImageSrc(localBackground); + const showPreview = safePreviewUrl && !previewError; return ( @@ -174,9 +186,12 @@ export function ChangeBackgroundDialog({ - {safePreviewUrl && ( + {showPreview && (
- + setPreviewError(true)} + />
diff --git a/frontend/e2e/playwright-report/index.html b/frontend/e2e/playwright-report/index.html deleted file mode 100644 index 815e715..0000000 --- a/frontend/e2e/playwright-report/index.html +++ /dev/null @@ -1,85 +0,0 @@ - - - - - - - - - Playwright Test Report - - - - -
- - - \ No newline at end of file diff --git a/frontend/lib/actions/users.ts b/frontend/lib/actions/users.ts index cbb0d88..25ee919 100644 --- a/frontend/lib/actions/users.ts +++ b/frontend/lib/actions/users.ts @@ -120,52 +120,67 @@ export async function updateUser(id: string, input: UpdateUserInput): Promise { +function getUploadBaseUrl(): string { const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/graphql'; - const baseUrl = apiUrl.replace(/\/graphql\/?$/, '') || 'http://localhost:4000'; + const base = (apiUrl || '').trim().replace(/\/graphql\/?$/i, ''); + return base || 'http://localhost:4000'; +} + +async function uploadFile( + endpoint: 'avatar' | 'background', + field: 'avatar' | 'background', + file: File, +): Promise<{ url: string }> { + const baseUrl = getUploadBaseUrl(); const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null; + if (!token?.trim()) { + throw new Error('Vous devez être connecté pour envoyer une image.'); + } const formData = new FormData(); - formData.append('avatar', file); - const res = await fetch(`${baseUrl}/api/upload/avatar`, { - method: 'POST', - headers: token ? { Authorization: `Bearer ${token}` } : {}, - body: formData, - credentials: 'include', - }); + formData.append(field, file); + const uploadUrl = `${baseUrl.replace(/\/$/, '')}/api/upload/${endpoint}`; + let res: Response; + try { + res = await fetch(uploadUrl, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: formData, + credentials: 'include', + }); + } catch (networkError) { + const msg = networkError instanceof Error ? networkError.message : 'Erreur réseau'; + throw new Error(`Impossible de contacter le serveur (${uploadUrl}): ${msg}`); + } if (!res.ok) { const err = await res.json().catch(() => ({ message: res.statusText })); const msg = err?.message; - const text = Array.isArray(msg) ? msg.join(', ') : typeof msg === 'string' ? msg : 'Upload failed'; - throw new Error(text); + let text = Array.isArray(msg) ? msg.join(', ') : typeof msg === 'string' ? msg : res.statusText; + if (res.status === 401) { + text = 'Session expirée ou non autorisée. Reconnectez-vous puis réessayez.'; + } else if (res.status === 404) { + text = "Point d’upload introuvable. Vérifiez que NEXT_PUBLIC_API_URL pointe vers le backend."; + } else if (res.status === 413) { + text = 'Fichier trop volumineux.'; + } + throw new Error(text || 'Échec de l’upload.'); } - return res.json(); + const data = await res.json().catch(() => null); + if (data && typeof data.url === 'string') return { url: data.url }; + throw new Error('Réponse du serveur invalide (URL manquante).'); +} + +/** + * Upload avatar image. Backend saves the file and returns the new avatar URL. + */ +export async function uploadAvatar(file: File): Promise<{ url: string }> { + return uploadFile('avatar', 'avatar', file); } /** * Upload background image (board or card). Backend saves to GCS or disk and returns the public URL. */ export async function uploadBackground(file: File): Promise<{ url: string }> { - const apiUrl = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:4000/graphql'; - const baseUrl = apiUrl.replace(/\/graphql\/?$/, '') || 'http://localhost:4000'; - const token = typeof window !== 'undefined' ? localStorage.getItem('auth_token') : null; - const formData = new FormData(); - formData.append('background', file); - const res = await fetch(`${baseUrl}/api/upload/background`, { - method: 'POST', - headers: token ? { Authorization: `Bearer ${token}` } : {}, - body: formData, - credentials: 'include', - }); - if (!res.ok) { - const err = await res.json().catch(() => ({ message: res.statusText })); - const msg = err?.message; - const text = Array.isArray(msg) ? msg.join(', ') : typeof msg === 'string' ? msg : 'Upload failed'; - throw new Error(text); - } - return res.json(); + return uploadFile('background', 'background', file); } /** diff --git a/frontend/next.config.ts b/frontend/next.config.ts index a9a85a9..44dd9b3 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,19 +1,27 @@ import type { NextConfig } from "next"; +// Allow images from API host when uploads are served by backend (no GCS) +function getApiImagePatterns(): { protocol: 'http' | 'https'; hostname: string; pathname: string }[] { + const apiUrl = process.env.NEXT_PUBLIC_API_URL; + if (!apiUrl?.trim()) return []; + try { + const u = new URL(apiUrl.trim()); + if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') return []; + return [ + { protocol: u.protocol === 'https:' ? 'https' : 'http', hostname: u.hostname, pathname: '/**' }, + ]; + } catch { + return []; + } +} + const nextConfig: NextConfig = { output: 'standalone', images: { remotePatterns: [ - { - protocol: 'https', - hostname: 'storage.googleapis.com', - pathname: '/**', - }, - { - protocol: 'http', - hostname: 'localhost', - pathname: '/**', - }, + { protocol: 'https', hostname: 'storage.googleapis.com', pathname: '/**' }, + { protocol: 'http', hostname: 'localhost', pathname: '/**' }, + ...getApiImagePatterns(), ], }, };