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:
@@ -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
107
src/app/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
90
src/components/CookieBanner.tsx
Normal file
90
src/components/CookieBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
src/components/PasswordChangeForm.tsx
Normal file
155
src/components/PasswordChangeForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
src/components/ProfileForm.tsx
Normal file
118
src/components/ProfileForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/hooks/useCookieConsent.ts
Normal file
69
src/hooks/useCookieConsent.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
150
src/services/profile-actions.ts
Normal file
150
src/services/profile-actions.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user