feat: Add EU cookie banner and user settings page

Cookie Banner:
- GDPR-compliant consent banner
- Shows on first visit, stores consent in localStorage
- Two options: Accept All / Only Essential
- Dark theme with smooth animations

Settings Page (/settings):
- Profile form: display name editing
- Password change with validation
- Cookie settings info section
- Privacy links and account info

New files:
- src/hooks/useCookieConsent.ts
- src/components/CookieBanner.tsx
- src/components/ProfileForm.tsx
- src/components/PasswordChangeForm.tsx
- src/services/profile-actions.ts
- src/app/settings/page.tsx
This commit is contained in:
2025-12-26 21:30:00 +01:00
parent 9c5f538efb
commit 6c37481d17
8 changed files with 692 additions and 1 deletions

View File

@@ -10,6 +10,7 @@ import ActiveSessionBanner from "@/components/ActiveSessionBanner";
import MainContentWrapper from "@/components/MainContentWrapper";
import AuthListener from "@/components/AuthListener";
import SyncHandler from "@/components/SyncHandler";
import CookieBanner from "@/components/CookieBanner";
const inter = Inter({ subsets: ["latin"], variable: '--font-inter' });
@@ -56,6 +57,7 @@ export default function RootLayout({
<UploadQueue />
{children}
</MainContentWrapper>
<CookieBanner />
</SessionProvider>
</I18nProvider>
</body>

107
src/app/settings/page.tsx Normal file
View File

@@ -0,0 +1,107 @@
import { redirect } from 'next/navigation';
import { createClient } from '@/lib/supabase/server';
import { getProfile } from '@/services/profile-actions';
import ProfileForm from '@/components/ProfileForm';
import PasswordChangeForm from '@/components/PasswordChangeForm';
import { ArrowLeft, Settings, Cookie, Shield } from 'lucide-react';
import Link from 'next/link';
export const metadata = {
title: 'Einstellungen',
};
export default async function SettingsPage() {
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
redirect('/login');
}
const profile = await getProfile();
return (
<div className="min-h-screen bg-zinc-950">
{/* Header */}
<header className="sticky top-0 z-40 bg-zinc-950/80 backdrop-blur-lg border-b border-zinc-800">
<div className="max-w-2xl mx-auto px-4 py-4 flex items-center gap-4">
<Link
href="/"
className="p-2 -ml-2 text-zinc-400 hover:text-white transition-colors"
>
<ArrowLeft size={20} />
</Link>
<div className="flex items-center gap-2">
<Settings size={20} className="text-orange-500" />
<h1 className="text-lg font-bold text-white">Einstellungen</h1>
</div>
</div>
</header>
{/* Content */}
<main className="max-w-2xl mx-auto px-4 py-6 space-y-6">
{/* Profile Form */}
<ProfileForm
initialData={{
email: profile?.email,
display_name: profile?.display_name,
}}
/>
{/* Password Change Form */}
<PasswordChangeForm />
{/* Cookie Settings */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
<h2 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
<Cookie size={20} className="text-orange-500" />
Cookie-Einstellungen
</h2>
<p className="text-sm text-zinc-400 mb-4">
Diese App verwendet nur technisch notwendige Cookies für die Authentifizierung und funktionale Cookies für UI-Präferenzen.
</p>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2 text-zinc-300">
<Shield size={14} className="text-green-500" />
<span><strong>Notwendig:</strong> Supabase Auth Cookies</span>
</div>
<div className="flex items-center gap-2 text-zinc-300">
<Shield size={14} className="text-blue-500" />
<span><strong>Funktional:</strong> Sprache, UI-Status</span>
</div>
</div>
</div>
{/* Data & Privacy */}
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6">
<h2 className="text-lg font-bold text-white mb-4 flex items-center gap-2">
<Shield size={20} className="text-orange-500" />
Datenschutz
</h2>
<div className="space-y-4">
<p className="text-sm text-zinc-400">
Deine Daten werden sicher auf EU-Servern gespeichert.
</p>
<Link
href="/privacy"
className="inline-block text-sm text-orange-500 hover:text-orange-400 underline"
>
Datenschutzerklärung lesen
</Link>
</div>
</div>
{/* Account info */}
<div className="bg-zinc-800/50 border border-zinc-700 rounded-2xl p-4 text-center">
<p className="text-xs text-zinc-500">
Mitglied seit: {new Date(profile?.created_at || '').toLocaleDateString('de-DE', {
day: '2-digit',
month: 'long',
year: 'numeric'
})}
</p>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,90 @@
'use client';
import { motion, AnimatePresence } from 'framer-motion';
import { Cookie, Shield, X } from 'lucide-react';
import { useCookieConsent } from '@/hooks/useCookieConsent';
import Link from 'next/link';
/**
* EU GDPR-compliant cookie consent banner
* Shows on first visit, stores consent in localStorage
*/
export default function CookieBanner() {
const { hasConsent, isLoading, acceptAll, acceptEssential } = useCookieConsent();
// Don't render while loading or if consent already given
if (isLoading || hasConsent) {
return null;
}
return (
<AnimatePresence>
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="fixed bottom-0 left-0 right-0 z-[100] p-4 md:p-6"
>
<div className="max-w-4xl mx-auto bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="p-6 pb-4">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 bg-orange-500/10 rounded-xl">
<Cookie size={24} className="text-orange-500" />
</div>
<h3 className="text-lg font-bold text-white">Cookie-Einstellungen</h3>
</div>
<p className="text-zinc-400 text-sm leading-relaxed">
Wir verwenden Cookies, um die Funktionalität unserer App zu gewährleisten.
Wir setzen <span className="text-white font-medium">keine Tracking- oder Marketing-Cookies</span> ein.
</p>
{/* Cookie types */}
<div className="mt-4 space-y-2">
<div className="flex items-center gap-2 text-sm">
<Shield size={14} className="text-green-500" />
<span className="text-zinc-300">
<span className="font-medium text-white">Notwendig:</span> Authentifizierung & Sicherheit
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Shield size={14} className="text-blue-500" />
<span className="text-zinc-300">
<span className="font-medium text-white">Funktional:</span> Spracheinstellungen & UI-Präferenzen
</span>
</div>
</div>
</div>
{/* Actions */}
<div className="p-4 bg-zinc-950/50 border-t border-zinc-800 flex flex-col sm:flex-row gap-3">
<button
onClick={acceptEssential}
className="flex-1 px-4 py-3 text-sm font-bold text-zinc-400 hover:text-white border border-zinc-700 hover:border-zinc-600 rounded-xl transition-all"
>
Nur Notwendige
</button>
<button
onClick={acceptAll}
className="flex-1 px-4 py-3 text-sm font-bold text-zinc-900 bg-orange-500 hover:bg-orange-400 rounded-xl transition-all"
>
Alle akzeptieren
</button>
</div>
{/* Privacy link */}
<div className="px-4 pb-4 text-center">
<Link
href="/privacy"
className="text-xs text-zinc-500 hover:text-zinc-400 underline"
>
Datenschutzerklärung
</Link>
</div>
</div>
</motion.div>
</AnimatePresence>
);
}

View File

@@ -0,0 +1,155 @@
'use client';
import { useState, useTransition } from 'react';
import { motion } from 'framer-motion';
import { Lock, Eye, EyeOff, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { changePassword } from '@/services/profile-actions';
export default function PasswordChangeForm() {
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isPending, startTransition] = useTransition();
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setStatus('idle');
setError(null);
if (newPassword !== confirmPassword) {
setStatus('error');
setError('Passwörter stimmen nicht überein');
return;
}
if (newPassword.length < 6) {
setStatus('error');
setError('Passwort muss mindestens 6 Zeichen lang sein');
return;
}
const formData = new FormData();
formData.set('new_password', newPassword);
formData.set('confirm_password', confirmPassword);
startTransition(async () => {
const result = await changePassword(formData);
if (result.success) {
setStatus('success');
setNewPassword('');
setConfirmPassword('');
setTimeout(() => setStatus('idle'), 3000);
} else {
setStatus('error');
setError(result.error || 'Fehler beim Ändern');
}
});
};
return (
<motion.form
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6"
onSubmit={handleSubmit}
>
<h2 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
<Lock size={20} className="text-orange-500" />
Passwort ändern
</h2>
<div className="space-y-4">
{/* New Password */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Neues Passwort
</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="••••••••"
className="w-full px-4 py-3 pr-12 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
{/* Confirm Password */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Passwort bestätigen
</label>
<input
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/>
</div>
</div>
{/* Password match indicator */}
{confirmPassword && (
<div className={`mt-2 text-xs flex items-center gap-1 ${newPassword === confirmPassword ? 'text-green-500' : 'text-red-500'
}`}>
{newPassword === confirmPassword ? (
<>
<CheckCircle size={12} />
Passwörter stimmen überein
</>
) : (
<>
<AlertCircle size={12} />
Passwörter stimmen nicht überein
</>
)}
</div>
)}
{/* Status messages */}
{status === 'success' && (
<div className="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-xl flex items-center gap-2 text-green-500 text-sm">
<CheckCircle size={16} />
Passwort erfolgreich geändert!
</div>
)}
{status === 'error' && (
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-2 text-red-500 text-sm">
<AlertCircle size={16} />
{error}
</div>
)}
{/* Submit button */}
<button
type="submit"
disabled={isPending || !newPassword || !confirmPassword}
className="mt-6 w-full px-4 py-3 bg-orange-500 hover:bg-orange-400 disabled:opacity-50 disabled:cursor-not-allowed text-zinc-900 font-bold rounded-xl flex items-center justify-center gap-2 transition-colors"
>
{isPending ? (
<>
<Loader2 size={18} className="animate-spin" />
Ändern...
</>
) : (
<>
<Lock size={18} />
Passwort ändern
</>
)}
</button>
</motion.form>
);
}

View File

@@ -0,0 +1,118 @@
'use client';
import { useState, useTransition } from 'react';
import { motion } from 'framer-motion';
import { User, Mail, Save, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
import { updateProfile } from '@/services/profile-actions';
interface ProfileFormProps {
initialData: {
email?: string;
display_name?: string | null;
};
}
export default function ProfileForm({ initialData }: ProfileFormProps) {
const [displayName, setDisplayName] = useState(initialData.display_name || '');
const [isPending, startTransition] = useTransition();
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
setStatus('idle');
setError(null);
const formData = new FormData();
formData.set('display_name', displayName);
startTransition(async () => {
const result = await updateProfile(formData);
if (result.success) {
setStatus('success');
setTimeout(() => setStatus('idle'), 3000);
} else {
setStatus('error');
setError(result.error || 'Fehler beim Speichern');
}
});
};
return (
<motion.form
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6"
onSubmit={handleSubmit}
>
<h2 className="text-lg font-bold text-white mb-6 flex items-center gap-2">
<User size={20} className="text-orange-500" />
Profil
</h2>
<div className="space-y-4">
{/* Email (read-only) */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
<Mail size={14} className="inline mr-2" />
E-Mail
</label>
<input
type="email"
value={initialData.email || ''}
disabled
className="w-full px-4 py-3 bg-zinc-800/50 border border-zinc-700 rounded-xl text-zinc-500 cursor-not-allowed"
/>
<p className="mt-1 text-xs text-zinc-500">E-Mail kann nicht geändert werden</p>
</div>
{/* Display Name */}
<div>
<label className="block text-sm font-medium text-zinc-400 mb-2">
Anzeigename
</label>
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="Dein Name"
className="w-full px-4 py-3 bg-zinc-800 border border-zinc-700 rounded-xl text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/>
</div>
</div>
{/* Status messages */}
{status === 'success' && (
<div className="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-xl flex items-center gap-2 text-green-500 text-sm">
<CheckCircle size={16} />
Profil gespeichert!
</div>
)}
{status === 'error' && (
<div className="mt-4 p-3 bg-red-500/10 border border-red-500/20 rounded-xl flex items-center gap-2 text-red-500 text-sm">
<AlertCircle size={16} />
{error}
</div>
)}
{/* Submit button */}
<button
type="submit"
disabled={isPending}
className="mt-6 w-full px-4 py-3 bg-orange-500 hover:bg-orange-400 disabled:opacity-50 disabled:cursor-not-allowed text-zinc-900 font-bold rounded-xl flex items-center justify-center gap-2 transition-colors"
>
{isPending ? (
<>
<Loader2 size={18} className="animate-spin" />
Speichern...
</>
) : (
<>
<Save size={18} />
Speichern
</>
)}
</button>
</motion.form>
);
}

View File

@@ -0,0 +1,69 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
export type ConsentLevel = 'all' | 'essential' | null;
interface CookieConsent {
level: ConsentLevel;
timestamp: string;
}
const CONSENT_KEY = 'cookie_consent';
/**
* Hook for managing GDPR-compliant cookie consent
*/
export function useCookieConsent() {
const [consent, setConsent] = useState<CookieConsent | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Load consent from localStorage on mount
useEffect(() => {
try {
const stored = localStorage.getItem(CONSENT_KEY);
if (stored) {
setConsent(JSON.parse(stored));
}
} catch (e) {
console.warn('[CookieConsent] Failed to load consent:', e);
}
setIsLoading(false);
}, []);
// Accept all cookies (essential + functional)
const acceptAll = useCallback(() => {
const newConsent: CookieConsent = {
level: 'all',
timestamp: new Date().toISOString(),
};
localStorage.setItem(CONSENT_KEY, JSON.stringify(newConsent));
setConsent(newConsent);
}, []);
// Accept only essential cookies
const acceptEssential = useCallback(() => {
const newConsent: CookieConsent = {
level: 'essential',
timestamp: new Date().toISOString(),
};
localStorage.setItem(CONSENT_KEY, JSON.stringify(newConsent));
setConsent(newConsent);
}, []);
// Reset consent (for settings page)
const resetConsent = useCallback(() => {
localStorage.removeItem(CONSENT_KEY);
setConsent(null);
}, []);
return {
hasConsent: consent !== null,
consentLevel: consent?.level ?? null,
consentTimestamp: consent?.timestamp ?? null,
isLoading,
acceptAll,
acceptEssential,
resetConsent,
};
}

View File

@@ -0,0 +1,150 @@
'use server';
import { createClient } from '@/lib/supabase/server';
import { revalidatePath } from 'next/cache';
export interface ProfileUpdateResult {
success: boolean;
error?: string;
}
/**
* Update user profile (display_name, avatar_url)
*/
export async function updateProfile(formData: FormData): Promise<ProfileUpdateResult> {
const supabase = await createClient();
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert' };
}
const displayName = formData.get('display_name') as string;
const { error } = await supabase
.from('profiles')
.upsert({
id: user.id,
display_name: displayName?.trim() || null,
updated_at: new Date().toISOString(),
});
if (error) {
console.error('[updateProfile] Error:', error);
return { success: false, error: error.message };
}
revalidatePath('/settings');
return { success: true };
} catch (error) {
console.error('[updateProfile] Error:', error);
return { success: false, error: 'Profil konnte nicht aktualisiert werden' };
}
}
/**
* Change user password
*/
export async function changePassword(formData: FormData): Promise<ProfileUpdateResult> {
const supabase = await createClient();
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert' };
}
const newPassword = formData.get('new_password') as string;
const confirmPassword = formData.get('confirm_password') as string;
if (!newPassword || newPassword.length < 6) {
return { success: false, error: 'Passwort muss mindestens 6 Zeichen lang sein' };
}
if (newPassword !== confirmPassword) {
return { success: false, error: 'Passwörter stimmen nicht überein' };
}
const { error } = await supabase.auth.updateUser({
password: newPassword
});
if (error) {
console.error('[changePassword] Error:', error);
return { success: false, error: error.message };
}
return { success: true };
} catch (error) {
console.error('[changePassword] Error:', error);
return { success: false, error: 'Passwort konnte nicht geändert werden' };
}
}
/**
* Get user profile data
*/
export async function getProfile() {
const supabase = await createClient();
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return null;
}
const { data: profile } = await supabase
.from('profiles')
.select('id, display_name, avatar_url, created_at, updated_at')
.eq('id', user.id)
.single();
return {
id: user.id,
email: user.email,
display_name: profile?.display_name || null,
avatar_url: profile?.avatar_url || null,
created_at: user.created_at,
};
} catch (error) {
console.error('[getProfile] Error:', error);
return null;
}
}
/**
* Delete user account (marks for deletion, doesn't immediately delete)
*/
export async function requestAccountDeletion(): Promise<ProfileUpdateResult> {
const supabase = await createClient();
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert' };
}
// Mark profile for deletion (actual deletion handled separately)
const { error } = await supabase
.from('profiles')
.update({
deletion_requested_at: new Date().toISOString(),
})
.eq('id', user.id);
if (error) {
console.error('[requestAccountDeletion] Error:', error);
return { success: false, error: error.message };
}
return { success: true };
} catch (error) {
console.error('[requestAccountDeletion] Error:', error);
return { success: false, error: 'Löschanfrage konnte nicht erstellt werden' };
}
}

File diff suppressed because one or more lines are too long