feat(admin): consentement aux annonces et désabonnement sans connexion
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m58s
CI / Deploy production (on server) (push) Successful in 26s

Les comptes déjà créés ne sont pas inscrits. Le choix est facultatif à l’inscription et dans les paramètres. Un lien public demande confirmation avant d’arrêter les actualités, sans toucher aux messages de compte.
This commit is contained in:
Antigravity
2026-09-06 09:23:10 +00:00
parent 6fa12f6e13
commit 360e58e473
33 changed files with 1036 additions and 9 deletions

View File

@@ -0,0 +1,121 @@
'use client'
import { Suspense, useEffect, useState } from 'react'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { CheckCircle2, AlertCircle, Mail } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { confirmMarketingUnsubscribe, previewUnsubscribeToken } from '@/app/actions/marketing-unsubscribe'
function UnsubscribeContent() {
const { t } = useLanguage()
const searchParams = useSearchParams()
const token = searchParams.get('token') || ''
const [status, setStatus] = useState<'idle' | 'saving' | 'ok' | 'already' | 'invalid'>(
token ? 'idle' : 'invalid',
)
useEffect(() => {
if (!token) return
let cancelled = false
void previewUnsubscribeToken(token).then((result) => {
if (!cancelled && !result.valid) setStatus('invalid')
})
return () => {
cancelled = true
}
}, [token])
const confirm = async () => {
if (!token) {
setStatus('invalid')
return
}
setStatus('saving')
const result = await confirmMarketingUnsubscribe(token)
if (result.invalid || !result.ok) setStatus('invalid')
else if (result.already) setStatus('already')
else setStatus('ok')
}
if (status === 'ok' || status === 'already') {
return (
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
<div className="w-14 h-14 mx-auto rounded-2xl bg-emerald-500/10 flex items-center justify-center">
<CheckCircle2 size={28} className="text-emerald-600" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-serif font-bold">{t('unsubscribe.successTitle')}</h1>
<p className="text-sm text-[var(--muted-foreground)] leading-relaxed">
{t('unsubscribe.successBody')}
</p>
</div>
<Link href="/login" className="block text-sm font-medium text-[var(--color-brand-accent)] hover:underline">
{t('unsubscribe.backToSignIn')}
</Link>
</div>
)
}
if (status === 'invalid') {
return (
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
<div className="w-14 h-14 mx-auto rounded-2xl bg-red-500/10 flex items-center justify-center">
<AlertCircle size={28} className="text-red-500" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-serif font-bold">{t('unsubscribe.invalidTitle')}</h1>
<p className="text-sm text-[var(--muted-foreground)] leading-relaxed">
{t('unsubscribe.invalidBody')}
</p>
</div>
<Link href="/settings/general" className="block text-sm font-medium text-[var(--color-brand-accent)] hover:underline">
{t('unsubscribe.openSettings')}
</Link>
</div>
)
}
return (
<div className="bg-white dark:bg-[var(--background)]/50 border border-[var(--border)] p-8 md:p-10 rounded-[48px] shadow-2xl text-center space-y-6">
<div className="w-14 h-14 mx-auto rounded-2xl bg-[var(--color-brand-accent)]/10 flex items-center justify-center">
<Mail size={28} className="text-[var(--color-brand-accent)]" />
</div>
<div className="space-y-2">
<h1 className="text-2xl font-serif font-bold">{t('unsubscribe.title')}</h1>
<p className="text-sm text-[var(--muted-foreground)] leading-relaxed">
{t('unsubscribe.body')}
</p>
</div>
<form
onSubmit={(e) => {
e.preventDefault()
void confirm()
}}
className="space-y-3"
>
<button
type="submit"
disabled={status === 'saving'}
className="w-full bg-[var(--foreground)] text-[var(--background)] py-4 rounded-2xl font-bold uppercase tracking-[0.2em] text-[10px] transition-all hover:shadow-xl active:scale-[0.98] disabled:opacity-50"
>
{t('unsubscribe.confirm')}
</button>
<Link
href="/home"
className="block text-xs text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
{t('unsubscribe.keep')}
</Link>
</form>
</div>
)
}
export default function UnsubscribePage() {
return (
<Suspense fallback={null}>
<UnsubscribeContent />
</Suspense>
)
}

View File

@@ -6,7 +6,8 @@ import { useLanguage } from '@/lib/i18n'
import { updateAISettings } from '@/app/actions/ai-settings'
import { toast } from 'sonner'
import { useRouter, useSearchParams } from 'next/navigation'
import { Globe, Bell, Shield, Brain, HelpCircle } from 'lucide-react'
import { Globe, Bell, Shield, Brain, HelpCircle, Mail } from 'lucide-react'
import { updateMyMarketingPreference } from '@/app/actions/marketing-preference'
import { motion } from 'motion/react'
import { openCookiePreferences } from '@/lib/consent/cookie-consent'
import { useAiConsent } from '@/components/legal/ai-consent-provider'
@@ -21,10 +22,14 @@ interface GeneralSettingsClientProps {
desktopNotifications: boolean
autoSave: boolean
}
initialMarketing?: {
optedIn: boolean
blocked: boolean
}
}
export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClientProps) {
const { t, setLanguage: setContextLanguage } = useLanguage()
export function GeneralSettingsClient({ initialSettings, initialMarketing }: GeneralSettingsClientProps) {
const { t, language: uiLanguage, setLanguage: setContextLanguage } = useLanguage()
const { hasAiConsent, revokeConsent, requestAiConsent } = useAiConsent()
const router = useRouter()
const searchParams = useSearchParams()
@@ -45,6 +50,9 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
const [emailNotifications, setEmailNotifications] = useState(initialSettings.emailNotifications ?? false)
const [desktopNotifications, setDesktopNotifications] = useState(initialSettings.desktopNotifications ?? false)
const [autoSave, setAutoSave] = useState(initialSettings.autoSave ?? true)
const [marketingOptIn, setMarketingOptIn] = useState(initialMarketing?.optedIn === true)
const [marketingBlocked] = useState(initialMarketing?.blocked === true)
const [marketingSaving, setMarketingSaving] = useState(false)
const handleLanguageChange = async (value: string) => {
setLanguage(value)
@@ -80,6 +88,20 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
toast.success(t('settings.settingsSaved'))
}
const handleMarketingChange = async (enabled: boolean) => {
if (marketingBlocked) return
setMarketingSaving(true)
setMarketingOptIn(enabled)
const result = await updateMyMarketingPreference(enabled, uiLanguage)
setMarketingSaving(false)
if (!result.ok) {
setMarketingOptIn(!enabled)
toast.error(t(result.error === 'blocked' ? 'settings.marketingBlocked' : 'settings.settingsSaveFailed'))
return
}
toast.success(t('settings.settingsSaved'))
}
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
@@ -222,6 +244,38 @@ export function GeneralSettingsClient({ initialSettings }: GeneralSettingsClient
</div>
</div>
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6 md:col-span-2">
<div className="flex items-start justify-between gap-4">
<div className="flex items-center gap-5">
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-concrete border border-border">
<Mail size={18} />
</div>
<div className="space-y-1">
<h4 className="text-base font-bold text-ink">{t('settings.marketingTitle')}</h4>
<p className="text-[11px] text-concrete max-w-2xl leading-relaxed">
{t('settings.marketingDescription')}
</p>
</div>
</div>
<label className={cn('relative inline-flex items-center shrink-0', marketingBlocked || marketingSaving ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer')}>
<input
type="checkbox"
className="sr-only peer"
checked={marketingOptIn}
disabled={marketingBlocked || marketingSaving}
onChange={(e) => handleMarketingChange(e.target.checked)}
/>
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-brand-accent" />
</label>
</div>
<p className="text-[12px] leading-relaxed text-ink/80">
{t('settings.marketingLegal')}
</p>
{marketingBlocked && (
<p className="text-[12px] text-rose-600">{t('settings.marketingBlocked')}</p>
)}
</div>
<div
ref={aiConsentSectionRef}
className={cn(

View File

@@ -1,6 +1,7 @@
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
import { getAISettings } from '@/app/actions/ai-settings'
import { getMyMarketingPreference } from '@/app/actions/marketing-preference'
import { GeneralSettingsClient } from './general-settings-client'
export default async function GeneralSettingsPage() {
@@ -15,10 +16,15 @@ export default async function GeneralSettingsPage() {
desktopNotifications,
autoSave,
} = await getAISettings()
const marketing = await getMyMarketingPreference()
return (
<GeneralSettingsClient
initialSettings={{ preferredLanguage, emailNotifications, desktopNotifications, autoSave }}
initialMarketing={{
optedIn: marketing?.optedIn === true,
blocked: marketing?.blocked === true,
}}
/>
)
}

View File

@@ -0,0 +1,29 @@
'use server'
import { auth } from '@/auth'
import { marketingConsentText } from '@/lib/marketing/consent-copy'
import { getMarketingPreferenceView, setMarketingOptIn } from '@/lib/marketing/preference'
import { marketingRequestMeta } from '@/lib/marketing/request-meta'
export async function getMyMarketingPreference() {
const session = await auth()
const userId = (session?.user as { id?: string } | undefined)?.id
if (!userId) return null
return getMarketingPreferenceView(userId)
}
export async function updateMyMarketingPreference(optedIn: boolean, language?: string) {
const session = await auth()
const userId = (session?.user as { id?: string } | undefined)?.id
if (!userId) return { ok: false as const, error: 'auth' as const }
const meta = await marketingRequestMeta()
const result = await setMarketingOptIn({
userId,
optedIn,
source: 'settings',
language: language || null,
meta,
})
if (!result.ok) return { ok: false as const, error: result.error }
return { ok: true as const, legalText: optedIn ? marketingConsentText(language) : null }
}

View File

@@ -0,0 +1,30 @@
'use server'
import { unsubscribeByUserId } from '@/lib/marketing/preference'
import { marketingRequestMeta } from '@/lib/marketing/request-meta'
import { verifyUnsubscribeToken } from '@/lib/marketing/unsubscribe-token'
export async function previewUnsubscribeToken(token: string | null): Promise<{
valid: boolean
}> {
if (!token) return { valid: false }
const payload = verifyUnsubscribeToken(token)
return { valid: Boolean(payload) }
}
export async function confirmMarketingUnsubscribe(token: string): Promise<{
ok: boolean
already?: boolean
invalid?: boolean
}> {
const payload = verifyUnsubscribeToken(token)
if (!payload) return { ok: false, invalid: true }
const meta = await marketingRequestMeta()
const result = await unsubscribeByUserId({
userId: payload.userId,
source: 'unsubscribe',
meta,
})
if (!result.ok) return { ok: false, invalid: true }
return { ok: true, already: result.already }
}

View File

@@ -50,7 +50,7 @@ export async function register(prevState: string | undefined, formData: FormData
const hashedPassword = await bcrypt.hash(password, 10);
const role = isAdmin ? 'ADMIN' : 'USER';
await prisma.user.create({
const created = await prisma.user.create({
data: {
email: normalizedEmail,
password: hashedPassword,
@@ -61,6 +61,25 @@ export async function register(prevState: string | undefined, formData: FormData
},
});
if (formData.get('marketingConsent') === '1') {
try {
const { setMarketingOptIn } = await import('@/lib/marketing/preference')
const { marketingRequestMeta } = await import('@/lib/marketing/request-meta')
const meta = await marketingRequestMeta()
await setMarketingOptIn({
userId: created.id,
optedIn: true,
source: 'register',
language: typeof formData.get('marketingLanguage') === 'string'
? String(formData.get('marketingLanguage'))
: null,
meta,
})
} catch (err) {
console.error('[register] marketing consent save failed:', err)
}
}
if (!isAdmin) {
const mailResult = await sendVerificationEmail({
email: normalizedEmail,

View File

@@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server'
import { unsubscribeByUserId } from '@/lib/marketing/preference'
import { publicAppOrigin, verifyUnsubscribeToken } from '@/lib/marketing/unsubscribe-token'
export const dynamic = 'force-dynamic'
function tokenFromRequest(request: NextRequest, bodyToken?: string | null): string | null {
return (
request.nextUrl.searchParams.get('token') ||
bodyToken ||
null
)
}
async function applyUnsubscribe(request: NextRequest, token: string | null) {
const payload = token ? verifyUnsubscribeToken(token) : null
if (!payload) {
return NextResponse.json({ ok: false }, { status: 400 })
}
const forwarded = request.headers.get('x-forwarded-for')
const ip = forwarded?.split(',')[0]?.trim() || request.headers.get('x-real-ip')
await unsubscribeByUserId({
userId: payload.userId,
source: 'unsubscribe-one-click',
meta: { ip, userAgent: request.headers.get('user-agent') },
})
return new NextResponse('OK', { status: 200 })
}
export async function POST(request: NextRequest) {
let bodyToken: string | null = null
const contentType = request.headers.get('content-type') || ''
try {
if (contentType.includes('application/x-www-form-urlencoded')) {
const form = await request.formData()
bodyToken = String(form.get('token') || '')
} else if (contentType.includes('application/json')) {
const json = await request.json().catch(() => null)
bodyToken = json?.token ? String(json.token) : null
}
} catch {
bodyToken = null
}
return applyUnsubscribe(request, tokenFromRequest(request, bodyToken))
}
export async function GET(request: NextRequest) {
const token = request.nextUrl.searchParams.get('token') || ''
const origin = publicAppOrigin() || request.nextUrl.origin
const url = new URL('/unsubscribe', origin)
if (token) url.searchParams.set('token', token)
return NextResponse.redirect(url, 303)
}