Files
thrilltrack-explorer/src/components/settings/AccountProfileTab.tsx
2025-11-01 15:22:30 +00:00

499 lines
18 KiB
TypeScript

import { useState, useEffect } from 'react';
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/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 { 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 { useAuth } from '@/hooks/useAuth';
import { useProfile } from '@/hooks/useProfile';
import { supabase } from '@/integrations/supabase/client';
import { Trash2, Mail, AlertCircle, X, Check, Loader2 } 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 { AccountDeletionDialog } from './AccountDeletionDialog';
import { DeletionStatusBanner } from './DeletionStatusBanner';
import { EmailChangeStatus } from './EmailChangeStatus';
import { usernameSchema, displayNameSchema, bioSchema } from '@/lib/validation';
import { z } from 'zod';
import { AccountDeletionRequest } from '@/types/database';
import { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
import { useAvatarUpload } from '@/hooks/useAvatarUpload';
import { useUsernameValidation } from '@/hooks/useUsernameValidation';
import { useAutoSave } from '@/hooks/useAutoSave';
import { formatDistanceToNow } from 'date-fns';
import { cn } from '@/lib/utils';
const profileSchema = z.object({
username: usernameSchema,
display_name: displayNameSchema,
bio: bioSchema
});
type ProfileFormData = z.infer<typeof profileSchema>;
export function AccountProfileTab() {
const { user, pendingEmail, clearPendingEmail } = useAuth();
const { data: profile, refreshProfile } = useProfile(user?.id);
const [loading, setLoading] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showEmailDialog, setShowEmailDialog] = useState(false);
const [showCancelEmailDialog, setShowCancelEmailDialog] = useState(false);
const [cancellingEmail, setCancellingEmail] = useState(false);
const [showDeletionDialog, setShowDeletionDialog] = useState(false);
const [deletionRequest, setDeletionRequest] = useState<AccountDeletionRequest | null>(null);
// Initialize avatar upload hook
const { avatarUrl, avatarImageId, isUploading: avatarLoading, uploadAvatar } = useAvatarUpload(
profile?.avatar_url || '',
profile?.avatar_image_id || '',
profile?.username || ''
);
const form = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
username: profile?.username || '',
display_name: profile?.display_name || '',
bio: profile?.bio || ''
}
});
// Username validation
const usernameValue = form.watch('username');
const usernameValidation = useUsernameValidation(usernameValue, profile?.username);
// Auto-save (disabled for now - can be enabled per user preference)
const formData = form.watch();
const { isSaving, lastSaved } = useAutoSave({
data: formData,
onSave: async (data) => {
const isValid = await form.trigger();
if (!isValid || usernameValidation.isAvailable === false) return;
await handleFormSubmit(data);
},
debounceMs: 3000,
enabled: false, // TODO: Get from user preferences
isValid: usernameValidation.isAvailable !== false
});
// Check for existing deletion request on mount (both pending and confirmed)
useEffect(() => {
const checkDeletionRequest = async () => {
if (!user?.id) return;
const { data, error } = await supabase
.from('account_deletion_requests')
.select('*')
.eq('user_id', user.id)
.in('status', ['pending', 'confirmed'])
.maybeSingle();
if (!error && data) {
setDeletionRequest(data);
}
};
checkDeletionRequest();
}, [user?.id]);
const handleFormSubmit = async (data: ProfileFormData) => {
if (!user) return;
setLoading(true);
try {
// Use the update_profile RPC function with server-side validation
const { data: result, error } = await supabase.rpc('update_profile', {
p_username: data.username,
p_display_name: data.display_name || null,
p_bio: data.bio || null
});
if (error) {
// Handle rate limiting error
if (error.code === 'P0001') {
const resetTime = error.message.match(/Try again at (.+)$/)?.[1];
throw new AppError(
error.message,
'RATE_LIMIT',
`Too many profile updates. ${resetTime ? 'Try again at ' + new Date(resetTime).toLocaleTimeString() : 'Please wait a few minutes.'}`
);
}
throw error;
}
// Type the RPC result
const rpcResult = result as unknown as { success: boolean; changes_count: number };
// Update Novu subscriber if username changed
if (rpcResult?.changes_count > 0 && notificationService.isEnabled()) {
await notificationService.updateSubscriber({
subscriberId: user.id,
email: user.email,
firstName: data.username,
});
}
await refreshProfile();
handleSuccess('Profile updated', 'Your profile has been successfully updated.');
} catch (error: unknown) {
handleError(error, { action: 'Update profile', userId: user.id });
} finally {
setLoading(false);
}
};
const onSubmit = async (data: ProfileFormData) => {
await handleFormSubmit(data);
};
const handleAvatarUpload = async (urls: string[], imageId?: string) => {
await uploadAvatar(urls, imageId);
await refreshProfile();
};
const handleCancelEmailChange = async () => {
if (!user?.email || !pendingEmail) return;
setCancellingEmail(true);
try {
// Ensure we have a valid session with access token
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
if (sessionError || !session?.access_token) {
throw new Error('Your session has expired. Please refresh the page and try again.');
}
// Call the edge function with explicit authorization header
const { data, error, requestId } = await invokeWithTracking(
'cancel-email-change',
{},
user.id
);
if (error) {
throw error;
}
if (!data?.success) {
throw new Error(data?.error || 'Failed to cancel email change');
}
// Clear the pending email state immediately without refreshing session
clearPendingEmail();
// 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],
});
}
handleSuccess('Email change cancelled', 'Your email change request has been cancelled.');
setShowCancelEmailDialog(false);
await refreshProfile();
} catch (error: unknown) {
handleError(error, { action: 'Cancel email change' });
} finally {
setCancellingEmail(false);
}
};
const handleDeletionRequested = async () => {
// Refresh deletion request data (check for both pending and confirmed)
const { data, error } = await supabase
.from('account_deletion_requests')
.select('*')
.eq('user_id', user!.id)
.in('status', ['pending', 'confirmed'])
.maybeSingle();
if (!error && data) {
setDeletionRequest(data);
}
};
const handleDeletionCancelled = () => {
setDeletionRequest(null);
};
const isDeactivated = profile?.deactivated || false;
return (
<div className="space-y-6">
{/* Deletion Status Banner */}
{deletionRequest && (
<DeletionStatusBanner
scheduledDate={deletionRequest.scheduled_deletion_at}
status={deletionRequest.status as 'pending' | 'confirmed'}
onCancelled={handleDeletionCancelled}
/>
)}
{/* Profile Picture + Account Info Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Profile Picture */}
<Card>
<CardHeader>
<CardTitle>Profile Picture</CardTitle>
<CardDescription>Upload your profile picture</CardDescription>
</CardHeader>
<CardContent>
<PhotoUpload
variant="avatar"
maxFiles={1}
maxSizeMB={1}
existingPhotos={avatarUrl ? [avatarUrl] : []}
onUploadComplete={handleAvatarUpload}
currentImageId={avatarImageId}
onError={(error) => {
handleError(new Error(error), { action: 'Upload avatar' });
}}
/>
</CardContent>
</Card>
{/* Account Information */}
<Card>
<CardHeader>
<CardTitle>Account Information</CardTitle>
<CardDescription>View your account details</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{pendingEmail && (
<EmailChangeStatus
currentEmail={user?.email || ''}
pendingEmail={pendingEmail}
onCancel={() => setShowCancelEmailDialog(true)}
/>
)}
<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
</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>
</CardContent>
</Card>
</div>
{/* Profile Information */}
<Card>
<CardHeader>
<CardTitle>Profile Information</CardTitle>
<CardDescription>Update your public profile details</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="username">
Username *
{usernameValidation.isChecking && (
<Loader2 className="inline ml-2 w-3 h-3 animate-spin" />
)}
</Label>
<Input
id="username"
{...form.register('username')}
placeholder="Enter your username"
disabled={isDeactivated}
className={cn(
usernameValidation.error && !form.formState.errors.username && "border-destructive",
usernameValidation.isAvailable && "border-green-500"
)}
/>
{usernameValidation.isChecking ? (
<p className="text-sm text-muted-foreground">
Checking availability...
</p>
) : usernameValidation.isAvailable === false && !form.formState.errors.username ? (
<p className="text-sm text-destructive flex items-center gap-1">
<X className="w-3 h-3" />
{usernameValidation.error}
</p>
) : usernameValidation.isAvailable === true && !form.formState.errors.username ? (
<p className="text-sm text-green-600 flex items-center gap-1">
<Check className="w-3 h-3" />
Username is available
</p>
) : form.formState.errors.username ? (
<p className="text-sm text-destructive">
{form.formState.errors.username.message}
</p>
) : null}
</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="flex items-center justify-between">
<Button
type="submit"
disabled={
loading ||
isDeactivated ||
isSaving ||
usernameValidation.isChecking ||
usernameValidation.isAvailable === false
}
>
{loading || isSaving ? 'Saving...' : 'Save Changes'}
</Button>
{lastSaved && !loading && !isSaving && (
<span className="text-sm text-muted-foreground">
Last saved {formatDistanceToNow(lastSaved, { addSuffix: true })}
</span>
)}
</div>
{isDeactivated && (
<p className="text-sm text-muted-foreground">
Your account is deactivated. Profile editing is disabled.
</p>
)}
</form>
</CardContent>
</Card>
{/* 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>
{/* Danger Zone */}
<Card className="border-destructive">
<CardHeader>
<CardTitle className="text-destructive">Danger Zone</CardTitle>
<CardDescription>
Permanently delete your account with a 2-week waiting period
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm font-medium">Delete Account</p>
<p className="text-sm text-muted-foreground">
Your profile and reviews will be deleted, but your contributions (submissions, photos) will be preserved
</p>
</div>
<Button
variant="destructive"
onClick={() => setShowDeletionDialog(true)}
disabled={!!deletionRequest}
>
<Trash2 className="w-4 h-4 mr-2" />
{deletionRequest ? 'Deletion Pending' : 'Delete Account'}
</Button>
</div>
</CardContent>
</Card>
{/* Account Deletion Dialog */}
<AccountDeletionDialog
open={showDeletionDialog}
onOpenChange={setShowDeletionDialog}
userEmail={user?.email || ''}
onDeletionRequested={handleDeletionRequested}
/>
</div>
);
}