fix: replace raw ai_consent_required code with translatable message and consent prompt
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 7m20s
CI / Deploy production (on server) (push) Successful in 24s

- Centralize AI consent 403 responses in server-consent helpers
- Return human-readable message + code + i18n errorKey from all AI APIs
- Update dashboard and AI clients to detect code/errorKey and translate
- Add consent.ai.requiredToast i18n keys (fr/en)
This commit is contained in:
Antigravity
2026-07-18 17:32:00 +00:00
parent 86505659cf
commit 324bf40658
41 changed files with 404 additions and 97 deletions

View File

@@ -3,7 +3,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { autoLabelCreationService } from '@/lib/ai/services'
import { getAISettings } from '@/app/actions/ai-settings'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenJson } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
/**
@@ -23,10 +23,7 @@ export async function POST(request: NextRequest) {
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ success: false, error: 'ai_consent_required' },
{ status: 403 }
)
return aiConsentForbiddenJson()
}
// Respect user's autoLabeling toggle

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { batchOrganizationService } from '@/lib/ai/services'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenJson } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
/**
@@ -20,10 +20,7 @@ export async function POST(request: NextRequest) {
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ success: false, error: 'ai_consent_required' },
{ status: 403 }
)
return aiConsentForbiddenJson()
}
// Get language from request headers or body

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getAISettings } from '@/app/actions/ai-settings'
import { describeImages } from '@/lib/ai/services/image-description.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(req: NextRequest) {
@@ -13,7 +13,7 @@ export async function POST(req: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const userSettings = await getAISettings(session.user.id)

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
/**
* GET /api/ai/echo/connections?noteId={id}&page={page}&limit={limit}
@@ -19,10 +19,7 @@ export async function GET(req: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ error: 'ai_consent_required' },
{ status: 403 }
)
return aiConsentForbiddenResponse()
}
// Get query parameters

View File

@@ -3,7 +3,7 @@ import { auth } from '@/auth'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import prisma from '@/lib/prisma'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
/**
@@ -22,7 +22,7 @@ export async function POST(req: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const body = await req.json()

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { memoryEchoService } from '@/lib/ai/services/memory-echo.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
/**
@@ -21,10 +21,7 @@ export async function GET(req: NextRequest) {
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ error: 'ai_consent_required' },
{ status: 403 }
)
return aiConsentForbiddenResponse()
}
// Get next insight (respects frequency limits)

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config'
import { getTagsProvider } from '@/lib/ai/factory'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(request: NextRequest) {
@@ -13,7 +13,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const { existingContent, resourceText, mode, language, format } = await request.json()

View File

@@ -0,0 +1,62 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { generateSlideDeck } from '@/lib/ai/services/slide-deck-generator.service'
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
if (!(await hasUserAiConsent())) {
return aiConsentForbiddenResponse()
}
const userId = session.user.id
const body = await request.json()
const { notebookId, theme, purpose, audience, slideCount, language } = body
if (!notebookId || typeof notebookId !== 'string') {
return NextResponse.json({ error: 'notebookId is required' }, { status: 400 })
}
const intent =
purpose || audience || slideCount
? {
purpose: purpose || 'auto',
audience: audience || 'auto',
slideCount: typeof slideCount === 'number' ? slideCount : undefined,
template: 'auto' as const,
}
: null
const result = await withAiQuota(
userId,
'slide_generate',
() =>
generateSlideDeck({
userId,
sourceNotebookId: notebookId,
theme: theme && theme !== 'auto' ? theme : null,
lang: language === 'en' ? 'en' : 'fr',
intent,
}),
{ lane: 'chat' },
)
if (!result.success) {
return NextResponse.json({ error: result.error || 'Failed to generate slides' }, { status: 500 })
}
return NextResponse.json(result)
} catch (error: unknown) {
const quotaResp = handleQuotaHttpError(error)
if (quotaResp) return quotaResp
console.error('[NotebookSlides] Error:', error)
const message = error instanceof Error ? error.message : 'Failed to generate slides'
return NextResponse.json({ error: message }, { status: 500 })
}
}

View File

@@ -2,7 +2,7 @@ import { rateLimit } from '@/lib/rate-limit'
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { notebookSummaryService } from '@/lib/ai/services'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenJson } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
/**
@@ -20,10 +20,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json(
{ success: false, error: 'ai_consent_required' },
{ status: 403 }
)
return aiConsentForbiddenJson()
}
const body = await request.json()

View File

@@ -3,7 +3,7 @@ import { auth } from '@/auth'
import { getSystemConfig } from '@/lib/config'
import { getTagsProvider } from '@/lib/ai/factory'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
export type PersonaId = 'engineer' | 'financial' | 'customer' | 'skeptic' | 'optimist'
@@ -70,7 +70,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const { content, title, personaId } = await request.json()

View File

@@ -3,7 +3,7 @@ import { auth } from '@/auth'
import { paragraphRefactorService } from '@/lib/ai/services/paragraph-refactor.service'
import { getAISettings } from '@/app/actions/ai-settings'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
export async function POST(request: NextRequest) {
try {
@@ -14,7 +14,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
// Respect user's paragraphRefactor toggle (Assistant IA)

View File

@@ -5,7 +5,7 @@ import { getSystemConfig } from '@/lib/config'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import { reserveUsageOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
export const maxDuration = 30
@@ -41,10 +41,7 @@ export async function POST(req: Request) {
}
if (!(await hasUserAiConsent())) {
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
})
return aiConsentForbiddenResponse()
}
const userId = session.user.id

View File

@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { notebookSuggestionService } from '@/lib/ai/services/notebook-suggestion.service'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(req: NextRequest) {
@@ -13,7 +13,7 @@ export async function POST(req: NextRequest) {
const userId = session.user.id
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const body = await req.json()

View File

@@ -6,7 +6,7 @@ import { runLaneWithBillingUser, willUseByokForLane } from '@/lib/ai/provider-fo
import { getSystemConfig } from '@/lib/config';
import { z } from 'zod';
import { checkEntitlementOrThrow, QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements';
import { hasUserAiConsent } from '@/lib/consent/server-consent';
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent';
import { getAISettings } from '@/app/actions/ai-settings';
@@ -25,7 +25,7 @@ export async function POST(req: NextRequest) {
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 });
return aiConsentForbiddenResponse();
}
const userSettings = await getAISettings(session.user.id);

View File

@@ -7,7 +7,7 @@ import { getAISettings } from '@/app/actions/ai-settings'
import { reserveAiUsageOrThrow } from '@/lib/ai-quota'
import { QuotaExceededError, QuotaServiceUnavailableError } from '@/lib/entitlements'
import { z } from 'zod'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
const requestSchema = z.object({
content: z.string().min(1, "Le contenu ne peut pas être vide"),
@@ -37,7 +37,7 @@ export async function POST(req: NextRequest) {
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const settings = await getAISettings(session.user.id)

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getChatProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { withAiQuota, handleQuotaHttpError } from '@/lib/ai-quota'
export async function POST(request: NextRequest) {
@@ -14,7 +14,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const { text } = await request.json()

View File

@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'
import { auth } from '@/auth'
import { getTagsProvider } from '@/lib/ai/factory'
import { getSystemConfig } from '@/lib/config'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
export async function POST(request: NextRequest) {
@@ -13,7 +13,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const { text, targetLanguage } = await request.json()

View File

@@ -6,7 +6,7 @@ import { getChatProvider } from '@/lib/ai/factory'
import { semanticSearchService } from '@/lib/ai/services/semantic-search.service'
import { prisma } from '@/lib/prisma'
import { auth } from '@/auth'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { loadTranslations, getTranslationValue, SupportedLanguage } from '@/lib/i18n'
import { toolRegistry } from '@/lib/ai/tools'
import { reserveAiUsageOrThrow, handleQuotaHttpError } from '@/lib/ai-quota'
@@ -65,10 +65,7 @@ export async function POST(req: Request) {
// GDPR AI Consent check
if (!(await hasUserAiConsent())) {
return new Response(JSON.stringify({ error: 'ai_consent_required' }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
})
return aiConsentForbiddenResponse()
}
// 1.5 Quota check (per-provider BYOK bypass — only when BYOK will be used for resolved provider)

View File

@@ -3,7 +3,7 @@ import { auth } from '@/auth'
import prisma from '@/lib/prisma'
import { getAISettings } from '@/app/actions/ai-settings'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { generateFlashcardsFromNote, type FlashcardStyle } from '@/lib/flashcards/generate-flashcards'
import { stripHtmlToText } from '@/lib/flashcards/deck-utils'
@@ -15,7 +15,7 @@ export async function POST(request: NextRequest) {
}
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
const userSettings = await getAISettings(session.user.id)

View File

@@ -4,7 +4,7 @@ import prisma from '@/lib/prisma'
import { contentModerationService, type ModerationResult } from '@/lib/ai/services/content-moderation.service'
import { publishEnhanceService } from '@/lib/ai/services/publish-enhance.service'
import { reserveUsageOrThrow, QuotaExceededError } from '@/lib/entitlements'
import { hasUserAiConsent } from '@/lib/consent/server-consent'
import { hasUserAiConsent, aiConsentForbiddenResponse } from '@/lib/consent/server-consent'
import { isPublishTemplateId } from '@/lib/publish/types'
import { computePublishedSourceHash, renderPublishedTemplate, renderRewrittenTemplate } from '@/lib/publish/template-render'
@@ -115,7 +115,7 @@ export async function POST(request: NextRequest) {
if (publishMode === 'ai') {
if (!(await hasUserAiConsent())) {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
return aiConsentForbiddenResponse()
}
if (!template || !isPublishTemplateId(template)) {
return NextResponse.json({ error: 'Invalid template' }, { status: 400 })

View File

@@ -128,7 +128,7 @@ export function AgentCard({ agent, onEdit, onRefresh, onToggle }: AgentCardProps
if (pollRef.current) clearInterval(pollRef.current)
pollRef.current = null
setIsRunning(false)
toast.error(t('agents.toasts.runError', { error: data.error || t('agents.toasts.runFailed') }), {
toast.error(t('agents.toasts.runError', { error: data.errorKey ? t(data.errorKey) : (data.error || t('agents.toasts.runFailed')) }), {
id: toastId,
description: '' // Clear the loading description
})

View File

@@ -122,7 +122,7 @@ export function AutoLabelSuggestionDialog({
onLabelsCreated()
onOpenChange(false)
} else {
toast.error(data.error || t('ai.autoLabels.error'))
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.autoLabels.error')))
}
} catch (error) {
console.error('Failed to create labels:', error)

View File

@@ -149,7 +149,7 @@ export function BatchOrganizationDialog({
onNotesMoved()
onOpenChange(false)
} else {
toast.error(data.error || t('ai.batchOrganization.applyFailed'))
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.batchOrganization.applyFailed')))
}
} catch (error) {
console.error('Failed to apply organization plan:', error)

View File

@@ -345,7 +345,7 @@ export function ContextualAIChat({
return
}
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.genericError')))
const descs = data.descriptions || []
let resultText = descs.map((d: any) =>
noteImages.length > 1 ? `**Image ${d.index + 1}:** ${d.description}` : d.description
@@ -381,7 +381,7 @@ export function ContextualAIChat({
return
}
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.genericError')))
const result = data[action.resultKey] || ''
setActionPreview({ label: t(action.i18nKey), text: result, asRichText: action.id === 'toRichText' })
} catch (e: any) {
@@ -457,7 +457,7 @@ export function ContextualAIChat({
})
const data = await res.json()
if (!res.ok || !data.success) {
mToast.error(data.error || t('ai.errorShort'), { id: toastId })
mToast.error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.errorShort')), { id: toastId })
setGenerateLoading(null)
return
}
@@ -577,7 +577,7 @@ export function ContextualAIChat({
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.resource.enrichError'))
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.resource.enrichError')))
setResourcePreview({ text: data.enrichedContent, source: resourceMode })
} catch (e: any) {
mToast.error(e.message || t('ai.resource.enrichError'))
@@ -622,7 +622,7 @@ export function ContextualAIChat({
}),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('ai.genericError')))
setResourcePreview({ text: data.enrichedContent, source: mode })
} catch (e: any) {
mToast.error(e.message || t('ai.resource.enrichErrorShort'))

View File

@@ -519,7 +519,7 @@ export function DashboardView({ onNoteSelect }: DashboardViewProps) {
try {
const res = await fetch('/api/ai/echo')
const json = await res.json()
if (res.status === 403 && json.error === 'ai_consent_required') { await requestAiConsent(); return }
if (res.status === 403 && json.code === 'ai_consent_required') { await requestAiConsent(); return }
if (!res.ok) throw new Error(json.error)
if (json.insight) {
await reloadBriefingAndPaths()

View File

@@ -64,11 +64,7 @@ export function FlashcardGenerateDialog({
})
const data = await res.json()
if (!res.ok) {
if (data.errorKey) {
toast.error(t(data.errorKey) || data.error)
} else {
toast.error(data.error || t('flashcards.generateFailed'))
}
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('flashcards.generateFailed')))
return
}
setCards(data.cards || [])

View File

@@ -353,7 +353,7 @@ export function RevisionView() {
const res = await fetch(`/api/flashcards/decks/${deckId}`)
const data = await res.json()
if (!res.ok) {
toast.error(data.error || t('flashcards.loadDeckFailed'))
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('flashcards.loadDeckFailed')))
return
}
setActiveDeckId(deckId)
@@ -432,7 +432,7 @@ export function RevisionView() {
const res = await fetch(`/api/flashcards/decks/${deckId}`)
const data = await res.json()
if (!res.ok) {
toast.error(data.error || t('flashcards.loadDeckFailed'))
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('flashcards.loadDeckFailed')))
setExpandedDeckId(null)
return
}

View File

@@ -69,7 +69,7 @@ export function FusionModal({
const data = await res.json()
if (!res.ok) {
throw new Error(data.error || 'Failed to generate fusion')
throw new Error(data.errorKey ? t(data.errorKey) : (data.error || 'Failed to generate fusion'))
}
if (!data.fusedNote) {

View File

@@ -20,7 +20,7 @@ import {
import { NotebookSuggestionToast } from '@/components/notebook-suggestion-toast'
import { Button } from '@/components/ui/button'
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, ChevronDown, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload, Globe } from 'lucide-react'
import { Plus, ArrowUpDown, Search, Sparkles, FileText, FolderOpen, ChevronRight, ChevronDown, Tag as TagIcon, X, Menu, LayoutGrid, List, Table, Columns3, CalendarDays, Wand2, Download, Upload, Globe, Presentation } from 'lucide-react'
import { emitNoteChange, NOTE_CHANGE_EVENT, type NoteChangeEvent } from '@/lib/note-change-sync'
import { useReminderCheck } from '@/hooks/use-reminder-check'
import { useAutoLabelSuggestion } from '@/hooks/use-auto-label-suggestion'
@@ -67,6 +67,10 @@ const NotebookSiteDialog = dynamic(
() => import('@/components/wizard/notebook-site-dialog').then(m => ({ default: m.NotebookSiteDialog })),
{ ssr: false }
)
const NotebookSlidesDialog = dynamic(
() => import('@/components/wizard/notebook-slides-dialog').then(m => ({ default: m.NotebookSlidesDialog })),
{ ssr: false }
)
const StructuredViewsIntro = dynamic(
() => import('@/components/structured-views/structured-views-intro').then(m => ({ default: m.StructuredViewsIntro })),
{ ssr: false }
@@ -151,6 +155,7 @@ export function HomeClient({
const [isEnablingStructured, setIsEnablingStructured] = useState(false)
const [showStructuredWizard, setShowStructuredWizard] = useState(false)
const [showNotebookSite, setShowNotebookSite] = useState(false)
const [showNotebookSlides, setShowNotebookSlides] = useState(false)
const [aiMenuOpen, setAiMenuOpen] = useState(false)
const aiMenuRef = useRef<HTMLDivElement>(null)
const [showStudyPlanner, setShowStudyPlanner] = useState(false)
@@ -179,7 +184,7 @@ export function HomeClient({
toast.success(`${data.created} notes importées !`)
window.location.reload()
} else {
toast.error(data.error || 'Erreur')
toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur'))
}
}
input.click()
@@ -1136,6 +1141,11 @@ export function HomeClient({
label: t('notebookSite.shortTitle') || 'Site web',
action: () => { setShowNotebookSite(true); setAiMenuOpen(false) },
},
{
icon: <Presentation size={14} />,
label: t('notebook.slides') || 'Présentation',
action: () => { setShowNotebookSlides(true); setAiMenuOpen(false) },
},
].map((item, i) => (
<button
key={i}
@@ -1452,6 +1462,14 @@ export function HomeClient({
onClose={() => setShowNotebookSite(false)}
/>
)}
{showNotebookSlides && currentNotebook && (
<NotebookSlidesDialog
notebookId={currentNotebook.id}
notebookName={currentNotebook.name}
onClose={() => setShowNotebookSlides(false)}
/>
)}
</div>
)
}

View File

@@ -426,7 +426,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return
}
const errorData = await response.json()
throw new Error(errorData.error || t('ai.titleGenerationError'))
throw new Error(errorData.errorKey ? t(errorData.errorKey) : (errorData.error || t('ai.titleGenerationError')))
}
const data = await response.json()
@@ -502,7 +502,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return
}
const errorData = await response.json()
throw new Error(errorData.error || t('ai.reformulationError'))
throw new Error(errorData.errorKey ? t(errorData.errorKey) : (errorData.error || t('ai.reformulationError')))
}
const data = await response.json()
@@ -542,7 +542,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return
}
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.clarifyFailed'))
if (!response.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('notes.clarifyFailed')))
setContentImmediate(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
@@ -575,7 +575,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return
}
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.shortenFailed'))
if (!response.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('notes.shortenFailed')))
setContentImmediate(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
@@ -608,7 +608,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
return
}
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.improveFailed'))
if (!response.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('notes.improveFailed')))
setContentImmediate(data.reformulatedText || data.text)
toast.success(t('ai.reformulationApplied'))
} catch (error) {
@@ -642,7 +642,7 @@ export function NoteEditorProvider({ note, readOnly = false, fullPage = false, o
body: JSON.stringify({ text: content })
})
const data = await response.json()
if (!response.ok) throw new Error(data.error || t('notes.transformFailed'))
if (!response.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('notes.transformFailed')))
setContentImmediate(data.transformedText)
setIsMarkdown(true)

View File

@@ -330,7 +330,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
duration: 6000,
})
} else {
toast.error(data.error || t('general.error'))
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('general.error')))
}
} catch {
toast.error(t('general.error'))
@@ -408,7 +408,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
})
} else {
toast.dismiss(toastId)
toast.error(data.error || t('general.error'))
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('general.error')))
}
} catch {
toast.dismiss(toastId)
@@ -437,7 +437,7 @@ export function NoteEditorToolbar({ mode, onClose, onToggleAttachments, attachme
setPublishOpen(false)
} else {
const data = await res.json().catch(() => ({}))
toast.error(data.error || t('general.error'))
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('general.error')))
}
} catch {
toast.error(t('general.error'))

View File

@@ -46,7 +46,7 @@ export function PublishDialog({ open, onClose, noteId, noteTitle, isPublic: init
duration: 6000,
})
} else {
toast.error(data.error || 'Erreur')
toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur'))
}
} catch { toast.error('Erreur') }
finally { setLoading(false) }

View File

@@ -68,7 +68,7 @@ export function NotebookSummaryDialog({
if (data.success && data.data) {
setSummary(data.data)
} else {
toast.error(data.error || t('notebook.summaryError'))
toast.error(data.errorKey ? t(data.errorKey) : (data.error || t('notebook.summaryError')))
onOpenChange(false)
}
} catch (error) {

View File

@@ -124,7 +124,7 @@ export function PersonasPanel({ noteTitle, noteContent }: PersonasPanelProps) {
body: JSON.stringify({ content: noteContent, title: noteTitle, personaId }),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('common.error'))
if (!res.ok) throw new Error(data.errorKey ? t(data.errorKey) : (data.error || t('common.error')))
setResults(prev => new Map(prev).set(personaId, data))
setExpanded(personaId)
} catch {

View File

@@ -2005,7 +2005,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
}),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur'); return }
if (!res.ok) { toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur')); return }
let html = data.reformulatedText || data.text || ''
// Clean up excessive whitespace
html = html

View File

@@ -100,7 +100,7 @@ export function AiNotebookWizard({ onClose, onComplete }: { onClose: () => void;
} else if (data.errorKey === 'ai.quotaExceeded') {
toast.error(t('ai.quotaExceeded') || 'Quota IA dépassé')
} else {
toast.error(data.error || 'Erreur')
toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur'))
}
setLoading(false)
return

View File

@@ -100,7 +100,7 @@ export function NotebookSiteDialog({ notebookId, notebookName, onClose }: Notebo
body: JSON.stringify({ selectedNoteIds: selected, template, description }),
})
const data = await res.json()
if (!res.ok) { toast.error(data.error || 'Erreur'); setStep('selection'); return }
if (!res.ok) { toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur')); setStep('selection'); return }
setExistingSlug(data.slug)
setSiteUrl(data.url)
setStep('done')

View File

@@ -0,0 +1,237 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { Presentation, X } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { toast } from 'sonner'
import { useRouter } from 'next/navigation'
const THEMES = [
'auto',
'Architectural SaaS',
'Midnight Cathedral',
'Aurora Borealis',
'Tokyo Neon',
'Sunlit Gallery',
'Clinical Precision',
'Venture Pitch',
'Forest Floor',
'Steel & Glass',
'Cyberpunk Terminal',
'Editorial Ink',
'Coastal Morning',
'Paper Studio',
]
const PURPOSES = [
{ value: 'auto', key: 'ai.generate.purposeAuto' },
{ value: 'course', key: 'ai.generate.purposeCourse' },
{ value: 'board', key: 'ai.generate.purposeBoard' },
{ value: 'project', key: 'ai.generate.purposeProject' },
{ value: 'strategy', key: 'ai.generate.purposeStrategy' },
{ value: 'pitch', key: 'ai.generate.purposePitch' },
{ value: 'summary', key: 'ai.generate.purposeSummary' },
]
const STATUS_KEYS = [
'notebook.slidesStatus1',
'notebook.slidesStatus2',
'notebook.slidesStatus3',
'notebook.slidesStatus4',
]
export function NotebookSlidesDialog({
notebookId,
notebookName,
onClose,
}: {
notebookId: string
notebookName: string
onClose: () => void
}) {
const { t, language } = useLanguage()
const router = useRouter()
const [loading, setLoading] = useState(false)
const [theme, setTheme] = useState('auto')
const [purpose, setPurpose] = useState('auto')
const [progress, setProgress] = useState(0)
const timerRef = useRef<ReturnType<typeof setInterval>>(null)
useEffect(() => {
return () => {
if (timerRef.current) clearInterval(timerRef.current)
}
}, [])
const handleGenerate = async () => {
setLoading(true)
setProgress(0)
let elapsed = 0
timerRef.current = setInterval(() => {
elapsed += 0.6
setProgress(() => {
const target = 85 * (1 - Math.exp(-elapsed / 15))
return Math.min(target, 85)
})
}, 600)
try {
const res = await fetch('/api/ai/notebook-slides', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
notebookId,
theme,
purpose,
language: language === 'fr' ? 'fr' : 'en',
}),
})
const data = await res.json()
if (timerRef.current) clearInterval(timerRef.current)
setProgress(100)
if (!res.ok) {
toast.error(
data.errorKey === 'ai.featureLocked'
? (t('ai.featureLocked') || 'Plan requis')
: (data.error || 'Erreur'),
)
} else if (data.success && data.canvasId) {
window.dispatchEvent(new Event('ai-usage-changed'))
toast.success(t('notebook.slidesReady') || `Présentation générée (${data.slideCount} slides)`)
setTimeout(() => {
router.push(`/lab?canvas=${data.canvasId}`)
onClose()
}, 400)
} else {
toast.error(data.errorKey ? t(data.errorKey) : (data.error || 'Erreur lors de la génération'))
}
} catch (e: any) {
if (timerRef.current) clearInterval(timerRef.current)
toast.error(e.message || 'Erreur')
} finally {
setLoading(false)
}
}
const statusIdx = Math.min(Math.floor(progress / 22), STATUS_KEYS.length - 1)
return (
<div
className="fixed inset-0 z-[300] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
dir="auto"
onClick={loading ? undefined : onClose}
>
<div
className="w-full max-w-lg rounded-2xl border border-border bg-card shadow-2xl overflow-hidden flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-border/50 bg-brand-accent/5 shrink-0">
<div className="flex items-center gap-2">
<Presentation className="h-5 w-5 text-brand-accent" />
<h2 className="text-base font-semibold">
{t('notebook.slides') || 'Présentation'}
</h2>
</div>
<button
onClick={onClose}
disabled={loading}
className="p-1 rounded-lg hover:bg-muted text-muted-foreground disabled:opacity-30 disabled:cursor-not-allowed"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="p-6 space-y-5">
{!loading && (
<>
<p className="text-sm text-muted-foreground">
{t('notebook.slidesDescription') || `Génère une présentation à partir des notes du carnet « ${notebookName} ».`}
</p>
<div className="space-y-1.5">
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-foreground px-1">
{t('ai.generate.theme') || 'Thème'}
</span>
<select
value={theme}
onChange={(e) => setTheme(e.target.value)}
className="w-full bg-card/60 border border-border rounded-lg px-3 py-2 text-sm outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground"
>
{THEMES.map((th) => (
<option key={th} value={th}>
{th === 'auto' ? (t('ai.generate.themeAuto') || 'Auto') : th}
</option>
))}
</select>
</div>
<div className="space-y-1.5">
<span className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-foreground px-1">
{t('ai.generate.purpose') || 'Type de deck'}
</span>
<select
value={purpose}
onChange={(e) => setPurpose(e.target.value)}
className="w-full bg-card/60 border border-border rounded-lg px-3 py-2 text-sm outline-none focus:ring-1 ring-brand-accent/10 transition-all cursor-pointer text-foreground"
>
{PURPOSES.map((p) => (
<option key={p.value} value={p.value}>
{t(p.key) || p.value}
</option>
))}
</select>
</div>
<button
onClick={handleGenerate}
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-sm rounded-lg bg-brand-accent text-white hover:bg-brand-accent/90 transition-colors font-medium"
>
<Presentation className="h-4 w-4" />
{t('notebook.slidesGenerate') || 'Générer la présentation'}
</button>
</>
)}
{loading && (
<div className="space-y-5 py-4">
<div className="space-y-2">
<div className="w-full h-2.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-brand-accent to-brand-accent/70 rounded-full transition-all duration-700 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground animate-pulse">
{t(STATUS_KEYS[statusIdx])}
</span>
<span className="text-xs font-mono font-bold text-brand-accent tabular-nums">
{Math.round(progress)}%
</span>
</div>
</div>
<div className="flex items-center justify-between px-2">
{STATUS_KEYS.map((_, i) => (
<div
key={i}
className={`h-1 flex-1 mx-0.5 rounded-full transition-colors duration-300 ${
i <= statusIdx ? 'bg-brand-accent' : 'bg-muted'
}`}
/>
))}
</div>
<p className="text-[10px] text-muted-foreground/60 text-center">
{t('notebook.slidesGeneratingHint') || 'Outline + rédaction des slides'}
</p>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -25,6 +25,19 @@ export async function hasUserAiConsent(): Promise<boolean> {
return settings?.aiProcessingConsent ?? false
}
export function aiConsentForbiddenResponse() {
return NextResponse.json({ error: 'ai_consent_required' }, { status: 403 })
const DEFAULT_AI_CONSENT_MESSAGE = 'AI processing consent is required to use this feature.'
const AI_CONSENT_ERROR_KEY = 'consent.ai.requiredToast'
export function aiConsentForbiddenResponse(message?: string) {
return NextResponse.json(
{ error: message ?? DEFAULT_AI_CONSENT_MESSAGE, code: 'ai_consent_required', errorKey: AI_CONSENT_ERROR_KEY },
{ status: 403 }
)
}
export function aiConsentForbiddenJson(message?: string) {
return NextResponse.json(
{ success: false, error: message ?? DEFAULT_AI_CONSENT_MESSAGE, code: 'ai_consent_required', errorKey: AI_CONSENT_ERROR_KEY },
{ status: 403 }
)
}

View File

@@ -3806,7 +3806,8 @@
"revoked": "Consent not granted",
"revokedToast": "AI processing consent successfully revoked.",
"complianceBadge": "GDPR compliance",
"auditFailed": "Could not record your consent. Please try again."
"auditFailed": "Could not record your consent. Please try again.",
"requiredToast": "Enable AI processing in Settings → AI to use this feature."
}
},
"account": {

View File

@@ -3812,7 +3812,8 @@
"revoked": "Consentement non accordé",
"revokedToast": "Consentement IA révoqué avec succès.",
"complianceBadge": "Conformité RGPD",
"auditFailed": "Impossible d'enregistrer votre consentement. Réessayez."
"auditFailed": "Impossible d'enregistrer votre consentement. Réessayez.",
"requiredToast": "Activez le traitement IA dans Paramètres → IA pour utiliser cette fonctionnalité."
}
},
"account": {