feat(admin): consentement aux annonces et désabonnement sans connexion
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:
121
memento-note/app/(auth)/unsubscribe/page.tsx
Normal file
121
memento-note/app/(auth)/unsubscribe/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
29
memento-note/app/actions/marketing-preference.ts
Normal file
29
memento-note/app/actions/marketing-preference.ts
Normal 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 }
|
||||
}
|
||||
30
memento-note/app/actions/marketing-unsubscribe.ts
Normal file
30
memento-note/app/actions/marketing-unsubscribe.ts
Normal 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 }
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
53
memento-note/app/api/marketing/unsubscribe/route.ts
Normal file
53
memento-note/app/api/marketing/unsubscribe/route.ts
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user