mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 09:31:13 -05:00
469 lines
17 KiB
TypeScript
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>
|
|
);
|
|
} |