Files
thrilltrack-explorer/src/components/settings/AccountProfileTab.tsx
2025-10-01 15:48:20 +00:00

469 lines
17 KiB
TypeScript

import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { useToast } from '@/hooks/use-toast';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
import { User, Upload, Trash2, Mail, AlertCircle, X } from 'lucide-react';
import { PhotoUpload } from '@/components/upload/PhotoUpload';
import { notificationService } from '@/lib/notificationService';
import { EmailChangeDialog } from './EmailChangeDialog';
import { Badge } from '@/components/ui/badge';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { toast as sonnerToast } from 'sonner';
const profileSchema = z.object({
username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_-]+$/),
display_name: z.string().max(50).optional(),
bio: z.string().max(500).optional(),
preferred_pronouns: z.string().max(20).optional(),
show_pronouns: z.boolean(),
preferred_language: z.string()
});
type ProfileFormData = z.infer<typeof profileSchema>;
export function AccountProfileTab() {
const { user, profile, refreshProfile, pendingEmail } = useAuth();
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [avatarLoading, setAvatarLoading] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showEmailDialog, setShowEmailDialog] = useState(false);
const [showCancelEmailDialog, setShowCancelEmailDialog] = useState(false);
const [cancellingEmail, setCancellingEmail] = useState(false);
const [avatarUrl, setAvatarUrl] = useState<string>(profile?.avatar_url || '');
const [avatarImageId, setAvatarImageId] = useState<string>(profile?.avatar_image_id || '');
const form = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
username: profile?.username || '',
display_name: profile?.display_name || '',
bio: profile?.bio || '',
preferred_pronouns: profile?.preferred_pronouns || '',
show_pronouns: profile?.show_pronouns || false,
preferred_language: profile?.preferred_language || 'en'
}
});
const onSubmit = async (data: ProfileFormData) => {
if (!user) return;
setLoading(true);
try {
const usernameChanged = profile?.username !== data.username;
const { error } = await supabase
.from('profiles')
.update({
username: data.username,
display_name: data.display_name || null,
bio: data.bio || null,
preferred_pronouns: data.preferred_pronouns || null,
show_pronouns: data.show_pronouns,
preferred_language: data.preferred_language,
updated_at: new Date().toISOString()
})
.eq('user_id', user.id);
if (error) throw error;
// Update Novu subscriber if username changed
if (usernameChanged && notificationService.isEnabled()) {
await notificationService.updateSubscriber({
subscriberId: user.id,
email: user.email,
firstName: data.username, // Send username as firstName to Novu
});
}
await refreshProfile();
toast({
title: 'Profile updated',
description: 'Your profile has been successfully updated.'
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message || 'Failed to update profile',
variant: 'destructive'
});
} finally {
setLoading(false);
}
};
const handleAvatarUpload = async (urls: string[], imageId?: string) => {
if (!user || !urls[0]) return;
const newAvatarUrl = urls[0];
const newImageId = imageId || '';
// Update local state immediately
setAvatarUrl(newAvatarUrl);
setAvatarImageId(newImageId);
try {
const { error } = await supabase
.from('profiles')
.update({
avatar_url: newAvatarUrl,
avatar_image_id: newImageId,
updated_at: new Date().toISOString()
})
.eq('user_id', user.id);
if (error) throw error;
await refreshProfile();
toast({
title: 'Avatar updated',
description: 'Your avatar has been successfully updated.'
});
} catch (error: any) {
// Revert local state on error
setAvatarUrl(profile?.avatar_url || '');
setAvatarImageId(profile?.avatar_image_id || '');
toast({
title: 'Error',
description: error.message || 'Failed to update avatar',
variant: 'destructive'
});
}
};
const handleCancelEmailChange = async () => {
if (!user?.email || !pendingEmail) return;
setCancellingEmail(true);
try {
// Reset email to current email (effectively cancels the pending change)
const { error: updateError } = await supabase.auth.updateUser({
email: user.email
});
if (updateError) throw updateError;
// Update Novu subscriber back to current email
if (notificationService.isEnabled()) {
await notificationService.updateSubscriber({
subscriberId: user.id,
email: user.email,
firstName: profile?.username || user.email.split('@')[0],
});
}
// Log the cancellation in audit log
await supabase.from('admin_audit_log').insert({
admin_user_id: user.id,
target_user_id: user.id,
action: 'email_change_cancelled',
details: {
cancelled_email: pendingEmail,
current_email: user.email,
timestamp: new Date().toISOString()
}
});
sonnerToast.success('Email change cancelled', {
description: 'Your email change request has been cancelled.'
});
setShowCancelEmailDialog(false);
await refreshProfile();
} catch (error: any) {
sonnerToast.error('Failed to cancel email change', {
description: error.message || 'An error occurred while cancelling the email change.'
});
} finally {
setCancellingEmail(false);
}
};
const handleDeleteAccount = async () => {
if (!user) return;
try {
// This would typically involve multiple steps:
// 1. Anonymize or delete user data
// 2. Delete the auth user
// For now, we'll just show a message
toast({
title: 'Account deletion requested',
description: 'Please contact support to complete account deletion.',
variant: 'destructive'
});
} catch (error: any) {
toast({
title: 'Error',
description: error.message || 'Failed to delete account',
variant: 'destructive'
});
}
};
return (
<div className="space-y-8">
{/* Profile Picture */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Profile Picture</h3>
<PhotoUpload
variant="avatar"
maxFiles={1}
maxSizeMB={1}
existingPhotos={avatarUrl ? [avatarUrl] : []}
onUploadComplete={handleAvatarUpload}
currentImageId={avatarImageId}
onError={(error) => {
toast({
title: "Upload Error",
description: error,
variant: "destructive"
});
}}
/>
</div>
<Separator />
{/* Profile Information */}
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<h3 className="text-lg font-medium">Profile Information</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="username">Username *</Label>
<Input
id="username"
{...form.register('username')}
placeholder="Enter your username"
/>
{form.formState.errors.username && (
<p className="text-sm text-destructive">
{form.formState.errors.username.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="display_name">Display Name</Label>
<Input
id="display_name"
{...form.register('display_name')}
placeholder="Enter your display name"
/>
{form.formState.errors.display_name && (
<p className="text-sm text-destructive">
{form.formState.errors.display_name.message}
</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio</Label>
<Textarea
id="bio"
{...form.register('bio')}
placeholder="Tell us about yourself..."
rows={4}
/>
{form.formState.errors.bio && (
<p className="text-sm text-destructive">
{form.formState.errors.bio.message}
</p>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="preferred_pronouns">Preferred Pronouns</Label>
<Input
id="preferred_pronouns"
{...form.register('preferred_pronouns')}
placeholder="e.g., they/them, she/her, he/him"
/>
</div>
<div className="space-y-2">
<Label htmlFor="preferred_language">Preferred Language</Label>
<Select
value={form.watch('preferred_language')}
onValueChange={(value) => form.setValue('preferred_language', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="en">English</SelectItem>
<SelectItem value="es">Español</SelectItem>
<SelectItem value="fr">Français</SelectItem>
<SelectItem value="de">Deutsch</SelectItem>
<SelectItem value="it">Italiano</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<Button type="submit" disabled={loading}>
{loading ? 'Saving...' : 'Save Changes'}
</Button>
</form>
<Separator />
{/* Account Information */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Account Information</h3>
<div className="space-y-4">
{pendingEmail && (
<Alert className="border-blue-500/20 bg-blue-500/10">
<AlertCircle className="h-4 w-4 text-blue-500" />
<AlertTitle className="text-blue-500 flex items-center justify-between">
<span>Email Change in Progress</span>
<Button
variant="ghost"
size="sm"
onClick={() => setShowCancelEmailDialog(true)}
className="h-auto py-1 px-2 text-blue-500 hover:text-blue-600 hover:bg-blue-500/20"
>
<X className="h-4 w-4 mr-1" />
Cancel Change
</Button>
</AlertTitle>
<AlertDescription className="text-sm text-muted-foreground">
You have a pending email change to <strong>{pendingEmail}</strong>.
Please check both your current email ({user?.email}) and new email ({pendingEmail}) for confirmation links.
Both must be confirmed to complete the change.
</AlertDescription>
</Alert>
)}
<div className="p-4 bg-muted/50 rounded-lg space-y-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium">Email Address</p>
<div className="flex items-center gap-2 mt-1">
<p className="text-sm text-muted-foreground">{user?.email}</p>
{pendingEmail ? (
<Badge variant="secondary" className="bg-blue-500/10 text-blue-500 border-blue-500/20 text-xs">
Change Pending
</Badge>
) : user?.email_confirmed_at ? (
<Badge variant="secondary" className="text-xs">Verified</Badge>
) : (
<Badge variant="outline" className="text-xs">Pending Verification</Badge>
)}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setShowEmailDialog(true)}
disabled={!!pendingEmail}
>
<Mail className="w-4 h-4 mr-2" />
Change Email
</Button>
</div>
<Separator />
<div>
<p className="text-sm font-medium">Account Created</p>
<p className="text-sm text-muted-foreground mt-1">
{profile?.created_at ? new Date(profile.created_at).toLocaleDateString() : 'N/A'}
</p>
</div>
</div>
</div>
</div>
{/* Email Change Dialog */}
{user && (
<EmailChangeDialog
open={showEmailDialog}
onOpenChange={setShowEmailDialog}
currentEmail={user.email || ''}
userId={user.id}
/>
)}
{/* Cancel Email Change Dialog */}
<AlertDialog open={showCancelEmailDialog} onOpenChange={setShowCancelEmailDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel Email Change?</AlertDialogTitle>
<AlertDialogDescription>
This will cancel your pending email change to <strong>{pendingEmail}</strong>.
Your email will remain as <strong>{user?.email}</strong>.
You can start a new email change request at any time.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cancellingEmail}>Keep Change</AlertDialogCancel>
<AlertDialogAction
onClick={handleCancelEmailChange}
disabled={cancellingEmail}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{cancellingEmail ? 'Cancelling...' : 'Yes, Cancel Change'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Separator />
{/* Danger Zone */}
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>
These actions cannot be undone. Please be careful.
</CardDescription>
</CardHeader>
<CardContent>
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogTrigger asChild>
<Button variant="destructive" className="w-fit">
<Trash2 className="w-4 h-4 mr-2" />
Delete Account
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your account
and remove all your data from our servers, including your reviews,
ride credits, and profile information.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteAccount}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Yes, delete my account
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CardContent>
</Card>
</div>
);
}