mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 23:11:13 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
296
src-old/components/settings/AccountDeletionDialog.tsx
Normal file
296
src-old/components/settings/AccountDeletionDialog.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
import { useReducer } from 'react';
|
||||
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { Loader2, AlertTriangle, Info } from 'lucide-react';
|
||||
import {
|
||||
deletionDialogReducer,
|
||||
initialState,
|
||||
canProceedToConfirm,
|
||||
canRequestDeletion,
|
||||
canConfirmDeletion
|
||||
} from '@/lib/deletionDialogMachine';
|
||||
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
||||
|
||||
interface AccountDeletionDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
userEmail: string;
|
||||
onDeletionRequested: () => void;
|
||||
}
|
||||
|
||||
export function AccountDeletionDialog({ open, onOpenChange, userEmail, onDeletionRequested }: AccountDeletionDialogProps) {
|
||||
const [state, dispatch] = useReducer(deletionDialogReducer, initialState);
|
||||
|
||||
const handleRequestDeletion = async () => {
|
||||
if (!canRequestDeletion(state)) return;
|
||||
|
||||
// Phase 4: AAL2 check for security-critical operations
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (session) {
|
||||
// Check if user has MFA enrolled
|
||||
const { data: factorsData } = await supabase.auth.mfa.listFactors();
|
||||
const hasMFA = factorsData?.totp?.some(f => f.status === 'verified') || false;
|
||||
|
||||
if (hasMFA) {
|
||||
const jwt = session.access_token;
|
||||
const payload = JSON.parse(atob(jwt.split('.')[1]));
|
||||
const currentAal = payload.aal || 'aal1';
|
||||
|
||||
if (currentAal !== 'aal2') {
|
||||
handleError(
|
||||
new Error('Please verify your identity with MFA first'),
|
||||
{ action: 'Request account deletion' }
|
||||
);
|
||||
sessionStorage.setItem('mfa_step_up_required', 'true');
|
||||
sessionStorage.setItem('mfa_intended_path', '/settings');
|
||||
window.location.href = '/auth';
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
|
||||
try {
|
||||
const { data, error, requestId } = await invokeWithTracking(
|
||||
'request-account-deletion',
|
||||
{},
|
||||
(await supabase.auth.getUser()).data.user?.id
|
||||
);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Clear MFA session storage
|
||||
sessionStorage.removeItem('mfa_step_up_required');
|
||||
sessionStorage.removeItem('mfa_intended_path');
|
||||
|
||||
dispatch({
|
||||
type: 'REQUEST_DELETION',
|
||||
payload: { scheduledDate: data.scheduled_deletion_at }
|
||||
});
|
||||
onDeletionRequested();
|
||||
|
||||
handleSuccess('Deletion Requested', 'Check your email for the confirmation code.');
|
||||
} catch (error: unknown) {
|
||||
handleError(error, { action: 'Request account deletion' });
|
||||
dispatch({ type: 'SET_ERROR', payload: 'Failed to request deletion' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDeletion = async () => {
|
||||
if (!canConfirmDeletion(state)) {
|
||||
handleError(
|
||||
new Error('Please enter a 6-digit confirmation code and confirm you received it'),
|
||||
{ action: 'Confirm deletion' }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
|
||||
try {
|
||||
const { error, requestId } = await invokeWithTracking(
|
||||
'confirm-account-deletion',
|
||||
{ confirmation_code: state.confirmationCode },
|
||||
(await supabase.auth.getUser()).data.user?.id
|
||||
);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
handleSuccess(
|
||||
'Deletion Confirmed',
|
||||
'Your account has been deactivated and scheduled for permanent deletion.'
|
||||
);
|
||||
|
||||
// Refresh the page to show the deletion banner
|
||||
window.location.reload();
|
||||
} catch (error: unknown) {
|
||||
handleError(error, { action: 'Confirm account deletion' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendCode = async () => {
|
||||
dispatch({ type: 'SET_LOADING', payload: true });
|
||||
|
||||
try {
|
||||
const { error, requestId } = await invokeWithTracking(
|
||||
'resend-deletion-code',
|
||||
{},
|
||||
(await supabase.auth.getUser()).data.user?.id
|
||||
);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
handleSuccess('Code Resent', 'A new confirmation code has been sent to your email.');
|
||||
} catch (error: unknown) {
|
||||
handleError(error, { action: 'Resend deletion code' });
|
||||
} finally {
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch({ type: 'RESET' });
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={handleClose}>
|
||||
<AlertDialogContent className="max-w-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Delete Account
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-left space-y-4">
|
||||
{state.step === 'warning' && (
|
||||
<>
|
||||
<Alert>
|
||||
<Info className="w-4 h-4" />
|
||||
<AlertDescription>
|
||||
This action will permanently delete your account after a 2-week waiting period. Please read carefully.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<h4 className="font-semibold text-foreground mb-2">What will be DELETED:</h4>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>✗ Your profile information (username, bio, avatar, etc.)</li>
|
||||
<li>✗ Your reviews and ratings</li>
|
||||
<li>✗ Your personal preferences and settings</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold text-foreground mb-2">What will be PRESERVED:</h4>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>✓ Your database submissions (park creations, ride additions, edits)</li>
|
||||
<li>✓ Photos you've uploaded (shown as "Submitted by [deleted user]")</li>
|
||||
<li>✓ Edit history and contributions</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
<strong>24-Hour Code + 2-Week Waiting Period:</strong> After confirming with the code (within 24 hours), your account will be deactivated immediately. You'll then have 14 days to cancel before permanent deletion.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.step === 'confirm' && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
Are you absolutely sure? You'll receive a confirmation code via email. After confirming with the code, your account will be deactivated and scheduled for deletion in 2 weeks.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{state.step === 'code' && (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<Info className="w-4 h-4" />
|
||||
<AlertDescription>
|
||||
A 6-digit confirmation code has been sent to <strong>{userEmail}</strong>. Enter it below within 24 hours to confirm deletion. Your account will be deactivated and scheduled for deletion on{' '}
|
||||
<strong>{state.scheduledDate ? new Date(state.scheduledDate).toLocaleDateString() : '14 days from confirmation'}</strong>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmationCode">Confirmation Code</Label>
|
||||
<Input
|
||||
id="confirmationCode"
|
||||
type="text"
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
value={state.confirmationCode}
|
||||
onChange={(e) => dispatch({ type: 'UPDATE_CODE', payload: { code: e.target.value } })}
|
||||
className="text-center text-2xl tracking-widest"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="codeReceived"
|
||||
checked={state.codeReceived}
|
||||
onCheckedChange={() => dispatch({ type: 'TOGGLE_CODE_RECEIVED' })}
|
||||
/>
|
||||
<Label htmlFor="codeReceived" className="text-sm font-normal cursor-pointer">
|
||||
I have received the code in my email
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleResendCode}
|
||||
disabled={state.isLoading}
|
||||
className="w-full"
|
||||
>
|
||||
{state.isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Resend Code'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
{state.step === 'warning' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => dispatch({ type: 'CONTINUE_TO_CONFIRM' })}
|
||||
disabled={!canProceedToConfirm(state)}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.step === 'confirm' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => dispatch({ type: 'GO_BACK_TO_WARNING' })}>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleRequestDeletion}
|
||||
disabled={!canRequestDeletion(state)}
|
||||
>
|
||||
{state.isLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null}
|
||||
Request Deletion
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{state.step === 'code' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirmDeletion}
|
||||
disabled={!canConfirmDeletion(state)}
|
||||
>
|
||||
{state.isLoading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null}
|
||||
Verify Code & Deactivate Account
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
499
src-old/components/settings/AccountProfileTab.tsx
Normal file
499
src-old/components/settings/AccountProfileTab.tsx
Normal file
@@ -0,0 +1,499 @@
|
||||
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 '@/lib/supabaseClient';
|
||||
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 as AccountDeletionRequest);
|
||||
}
|
||||
};
|
||||
|
||||
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 || '',
|
||||
p_bio: data.bio || ''
|
||||
});
|
||||
|
||||
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 as AccountDeletionRequest);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
435
src-old/components/settings/DataExportTab.tsx
Normal file
435
src-old/components/settings/DataExportTab.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useProfile } from '@/hooks/useProfile';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { handleError, handleSuccess, handleNonCriticalError, AppError } from '@/lib/errorHandler';
|
||||
import { Download, Activity, BarChart3, AlertCircle, Clock } from 'lucide-react';
|
||||
import type {
|
||||
UserStatistics,
|
||||
ActivityLogEntry,
|
||||
ExportOptions,
|
||||
ExportRequestResult
|
||||
} from '@/types/data-export';
|
||||
import {
|
||||
exportOptionsSchema,
|
||||
DEFAULT_EXPORT_OPTIONS
|
||||
} from '@/lib/dataExportValidation';
|
||||
|
||||
export function DataExportTab() {
|
||||
const { user } = useAuth();
|
||||
const { data: profile } = useProfile(user?.id);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [statistics, setStatistics] = useState<UserStatistics | null>(null);
|
||||
const [recentActivity, setRecentActivity] = useState<ActivityLogEntry[]>([]);
|
||||
const [exportOptions, setExportOptions] = useState<ExportOptions>(DEFAULT_EXPORT_OPTIONS);
|
||||
const [rateLimited, setRateLimited] = useState(false);
|
||||
const [nextAvailableAt, setNextAvailableAt] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (user && profile) {
|
||||
loadStatistics();
|
||||
loadRecentActivity();
|
||||
}
|
||||
}, [user, profile]);
|
||||
|
||||
const loadStatistics = async () => {
|
||||
if (!user || !profile) return;
|
||||
|
||||
try {
|
||||
// Fetch additional counts
|
||||
const { count: photoCount } = await supabase
|
||||
.from('photos')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('submitted_by', user.id);
|
||||
|
||||
// Note: user_lists table needs to be created for this to work
|
||||
// For now, setting to 0 as placeholder
|
||||
const listCount = 0;
|
||||
|
||||
const { count: submissionCount } = await supabase
|
||||
.from('content_submissions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('user_id', user.id);
|
||||
|
||||
const stats: UserStatistics = {
|
||||
ride_count: profile.ride_count || 0,
|
||||
coaster_count: profile.coaster_count || 0,
|
||||
park_count: profile.park_count || 0,
|
||||
review_count: profile.review_count || 0,
|
||||
reputation_score: profile.reputation_score || 0,
|
||||
photo_count: photoCount || 0,
|
||||
list_count: listCount || 0,
|
||||
submission_count: submissionCount || 0,
|
||||
account_created: profile.created_at,
|
||||
last_updated: profile.updated_at
|
||||
};
|
||||
|
||||
setStatistics(stats);
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Load statistics',
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const loadRecentActivity = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('profile_audit_log')
|
||||
.select(`
|
||||
id,
|
||||
action,
|
||||
created_at,
|
||||
changed_by,
|
||||
ip_address_hash,
|
||||
user_agent,
|
||||
profile_change_fields(field_name, old_value, new_value)
|
||||
`)
|
||||
.eq('user_id', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Transform the data to match our type
|
||||
const activityData: ActivityLogEntry[] = (data || []).map(item => {
|
||||
const changes: Record<string, any> = {};
|
||||
if (item.profile_change_fields) {
|
||||
for (const field of item.profile_change_fields) {
|
||||
changes[field.field_name] = {
|
||||
old: field.old_value,
|
||||
new: field.new_value
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
action: item.action,
|
||||
changes,
|
||||
created_at: item.created_at,
|
||||
changed_by: item.changed_by,
|
||||
ip_address_hash: item.ip_address_hash || undefined,
|
||||
user_agent: item.user_agent || undefined
|
||||
};
|
||||
});
|
||||
|
||||
setRecentActivity(activityData);
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Load activity log',
|
||||
userId: user.id
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDataExport = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setExporting(true);
|
||||
|
||||
try {
|
||||
// Validate export options
|
||||
const validatedOptions = exportOptionsSchema.parse(exportOptions);
|
||||
|
||||
// Call edge function for secure export
|
||||
const { data, error, requestId } = await invokeWithTracking<ExportRequestResult>(
|
||||
'export-user-data',
|
||||
validatedOptions,
|
||||
user.id
|
||||
);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data?.success) {
|
||||
if (data?.rate_limited) {
|
||||
setRateLimited(true);
|
||||
setNextAvailableAt(data.next_available_at || null);
|
||||
|
||||
handleError(
|
||||
new AppError(
|
||||
'Rate limited',
|
||||
'RATE_LIMIT_EXCEEDED',
|
||||
data.error || 'You can export your data once per hour. Please try again later.'
|
||||
),
|
||||
{ action: 'Export data', userId: user.id }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(data?.error || 'Export failed');
|
||||
}
|
||||
|
||||
// Download the data as JSON
|
||||
const blob = new Blob([JSON.stringify(data.data, null, 2)], {
|
||||
type: 'application/json'
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `thrillwiki-data-export-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
handleSuccess(
|
||||
'Data exported successfully',
|
||||
'Your data has been downloaded as a JSON file.'
|
||||
);
|
||||
|
||||
// Refresh activity log to show the export action
|
||||
await loadRecentActivity();
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Export data',
|
||||
userId: user.id
|
||||
});
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatActionName = (action: string): string => {
|
||||
return action
|
||||
.split('_')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ');
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
return new Date(dateString).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-full max-w-md" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Statistics + Recent Activity Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Personal Statistics */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<BarChart3 className="w-5 h-5" />
|
||||
<CardTitle>Personal Statistics</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Your activity and contribution statistics on ThrillWiki
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{statistics && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Reviews</p>
|
||||
<p className="text-2xl font-bold">{statistics.review_count}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Rides Tracked</p>
|
||||
<p className="text-2xl font-bold">{statistics.ride_count}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Coasters</p>
|
||||
<p className="text-2xl font-bold">{statistics.coaster_count}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Parks Visited</p>
|
||||
<p className="text-2xl font-bold">{statistics.park_count}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Photos</p>
|
||||
<p className="text-2xl font-bold">{statistics.photo_count}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Lists</p>
|
||||
<p className="text-2xl font-bold">{statistics.list_count}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Submissions</p>
|
||||
<p className="text-2xl font-bold">{statistics.submission_count}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm text-muted-foreground">Reputation</p>
|
||||
<p className="text-2xl font-bold">{statistics.reputation_score}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Account Activity */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-5 h-5" />
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Recent account activity and changes
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentActivity.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-4">
|
||||
No recent activity to display
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{recentActivity.map((activity) => (
|
||||
<div key={activity.id} className="flex items-start gap-3 pb-4 border-b last:border-0 last:pb-0">
|
||||
<Activity className="w-4 h-4 text-muted-foreground mt-1" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{formatActionName(activity.action)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatDate(activity.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Export Your Data - Full Width */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="w-5 h-5" />
|
||||
<CardTitle>Export Your Data</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Export all your ThrillWiki data in JSON format. This includes your profile, reviews, lists, and activity history.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{rateLimited && nextAvailableAt && (
|
||||
<div className="flex items-start gap-3 p-4 border border-yellow-500/20 bg-yellow-500/10 rounded-lg">
|
||||
<Clock className="w-5 h-5 text-yellow-500 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Rate Limited</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You can export your data once per hour. Next export available at{' '}
|
||||
{formatDate(nextAvailableAt)}.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Choose what to include in your export:
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="include_reviews">Include Reviews</Label>
|
||||
<Switch
|
||||
id="include_reviews"
|
||||
checked={exportOptions.include_reviews}
|
||||
onCheckedChange={(checked) =>
|
||||
setExportOptions({ ...exportOptions, include_reviews: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="include_lists">Include Lists</Label>
|
||||
<Switch
|
||||
id="include_lists"
|
||||
checked={exportOptions.include_lists}
|
||||
onCheckedChange={(checked) =>
|
||||
setExportOptions({ ...exportOptions, include_lists: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="include_activity_log">Include Activity Log</Label>
|
||||
<Switch
|
||||
id="include_activity_log"
|
||||
checked={exportOptions.include_activity_log}
|
||||
onCheckedChange={(checked) =>
|
||||
setExportOptions({ ...exportOptions, include_activity_log: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="include_preferences">Include Preferences</Label>
|
||||
<Switch
|
||||
id="include_preferences"
|
||||
checked={exportOptions.include_preferences}
|
||||
onCheckedChange={(checked) =>
|
||||
setExportOptions({ ...exportOptions, include_preferences: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-4 border rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-blue-500 mt-0.5" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">GDPR Compliance</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This export includes all personal data we store about you. You can use this for backup purposes or to migrate to another service.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleDataExport}
|
||||
disabled={exporting || rateLimited}
|
||||
className="w-full"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
{exporting ? 'Exporting Data...' : 'Export My Data'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
src-old/components/settings/DeletionStatusBanner.tsx
Normal file
114
src-old/components/settings/DeletionStatusBanner.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { AlertTriangle, Loader2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
|
||||
interface DeletionStatusBannerProps {
|
||||
scheduledDate: string;
|
||||
status: 'pending' | 'confirmed';
|
||||
onCancelled: () => void;
|
||||
}
|
||||
|
||||
export function DeletionStatusBanner({ scheduledDate, status, onCancelled }: DeletionStatusBannerProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const calculateDaysRemaining = () => {
|
||||
const scheduled = new Date(scheduledDate);
|
||||
const now = new Date();
|
||||
const diffTime = scheduled.getTime() - now.getTime();
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
return Math.max(0, diffDays);
|
||||
};
|
||||
|
||||
const handleCancelDeletion = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { error, requestId } = await invokeWithTracking(
|
||||
'cancel-account-deletion',
|
||||
{ cancellation_reason: 'User cancelled from settings' },
|
||||
(await supabase.auth.getUser()).data.user?.id
|
||||
);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast({
|
||||
title: 'Deletion Cancelled',
|
||||
description: 'Your account has been reactivated.',
|
||||
});
|
||||
|
||||
onCancelled();
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = getErrorMessage(error);
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: 'Error',
|
||||
description: errorMsg,
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const daysRemaining = calculateDaysRemaining();
|
||||
const formattedDate = new Date(scheduledDate).toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
<AlertTitle>
|
||||
{status === 'pending' ? 'Deletion Requested' : 'Account Deactivated - Deletion Scheduled'}
|
||||
</AlertTitle>
|
||||
<AlertDescription className="space-y-2">
|
||||
{status === 'pending' ? (
|
||||
<>
|
||||
<p>
|
||||
You have requested account deletion. Please check your email for a confirmation code.
|
||||
You must enter the code within 24 hours to proceed.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
After confirming with the code, your account will be deactivated and scheduled for deletion on{' '}
|
||||
<strong>{formattedDate}</strong>.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
Your account is <strong>deactivated</strong> and scheduled for permanent deletion on{' '}
|
||||
<strong>{formattedDate}</strong>.
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{daysRemaining > 0 ? (
|
||||
<>
|
||||
<strong>{daysRemaining}</strong> day{daysRemaining !== 1 ? 's' : ''} remaining to cancel.
|
||||
</>
|
||||
) : (
|
||||
'Your account will be deleted within 24 hours.'
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div className="flex gap-2 mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelDeletion}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : null}
|
||||
Cancel Deletion
|
||||
</Button>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
406
src-old/components/settings/EmailChangeDialog.tsx
Normal file
406
src-old/components/settings/EmailChangeDialog.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { handleError, handleSuccess, handleNonCriticalError, AppError, getErrorMessage } from '@/lib/errorHandler';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { Loader2, Mail, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||
import { TurnstileCaptcha } from '@/components/auth/TurnstileCaptcha';
|
||||
import { useTheme } from '@/components/theme/ThemeProvider';
|
||||
import { notificationService } from '@/lib/notificationService';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { validateEmailNotDisposable } from '@/lib/emailValidation';
|
||||
|
||||
const emailSchema = z.object({
|
||||
currentPassword: z.string().min(1, 'Current password is required'),
|
||||
newEmail: z.string().email('Please enter a valid email address'),
|
||||
confirmEmail: z.string().email('Please enter a valid email address'),
|
||||
}).refine((data) => data.newEmail === data.confirmEmail, {
|
||||
message: "Email addresses don't match",
|
||||
path: ["confirmEmail"],
|
||||
});
|
||||
|
||||
type EmailFormData = z.infer<typeof emailSchema>;
|
||||
|
||||
type Step = 'verification' | 'success';
|
||||
|
||||
interface EmailChangeDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentEmail: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }: EmailChangeDialogProps) {
|
||||
const { theme } = useTheme();
|
||||
const [step, setStep] = useState<Step>('verification');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [captchaToken, setCaptchaToken] = useState<string>('');
|
||||
const [captchaKey, setCaptchaKey] = useState(0);
|
||||
|
||||
const form = useForm<EmailFormData>({
|
||||
resolver: zodResolver(emailSchema),
|
||||
defaultValues: {
|
||||
currentPassword: '',
|
||||
newEmail: '',
|
||||
confirmEmail: '',
|
||||
},
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
if (loading) return;
|
||||
|
||||
onOpenChange(false);
|
||||
setTimeout(() => {
|
||||
setStep('verification');
|
||||
form.reset();
|
||||
setCaptchaToken('');
|
||||
setCaptchaKey(prev => prev + 1);
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: EmailFormData) => {
|
||||
if (!captchaToken) {
|
||||
handleError(
|
||||
new AppError('Please complete the CAPTCHA verification.', 'CAPTCHA_REQUIRED'),
|
||||
{ action: 'Change email', userId, metadata: { step: 'captcha_validation' } }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.newEmail.toLowerCase() === currentEmail.toLowerCase()) {
|
||||
handleError(
|
||||
new AppError('The new email is the same as your current email.', 'SAME_EMAIL'),
|
||||
{ action: 'Change email', userId, metadata: { currentEmail, newEmail: data.newEmail } }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 4: AAL2 check for security-critical operations
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (session) {
|
||||
// Check if user has MFA enrolled
|
||||
const { data: factorsData } = await supabase.auth.mfa.listFactors();
|
||||
const hasMFA = factorsData?.totp?.some(f => f.status === 'verified') || false;
|
||||
|
||||
if (hasMFA) {
|
||||
const jwt = session.access_token;
|
||||
const payload = JSON.parse(atob(jwt.split('.')[1]));
|
||||
const currentAal = payload.aal || 'aal1';
|
||||
|
||||
if (currentAal !== 'aal2') {
|
||||
handleError(
|
||||
new AppError(
|
||||
'Please verify your identity with MFA first',
|
||||
'AAL2_REQUIRED'
|
||||
),
|
||||
{ action: 'Change email', userId, metadata: { step: 'aal2_check' } }
|
||||
);
|
||||
sessionStorage.setItem('mfa_step_up_required', 'true');
|
||||
sessionStorage.setItem('mfa_intended_path', '/settings?tab=security');
|
||||
window.location.href = '/auth';
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Step 1: Validate email is not disposable
|
||||
const emailValidation = await validateEmailNotDisposable(data.newEmail);
|
||||
|
||||
if (!emailValidation.valid) {
|
||||
handleError(
|
||||
new AppError(
|
||||
emailValidation.reason || "Please use a permanent email address",
|
||||
'DISPOSABLE_EMAIL'
|
||||
),
|
||||
{ action: 'Validate email', userId, metadata: { email: data.newEmail } }
|
||||
);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Reauthenticate with current password
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||
email: currentEmail,
|
||||
password: data.currentPassword,
|
||||
options: {
|
||||
captchaToken
|
||||
}
|
||||
});
|
||||
|
||||
if (signInError) {
|
||||
// Reset CAPTCHA on authentication failure
|
||||
setCaptchaToken('');
|
||||
setCaptchaKey(prev => prev + 1);
|
||||
throw signInError;
|
||||
}
|
||||
|
||||
// Step 3: Update email address
|
||||
// Supabase will send verification emails to both old and new addresses
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
email: data.newEmail
|
||||
});
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
// Step 4: Novu subscriber will be updated automatically after both emails are confirmed
|
||||
// This happens in the useAuth hook when the email change is fully verified
|
||||
|
||||
// Step 5: Log the email change attempt
|
||||
supabase.from('admin_audit_log').insert({
|
||||
admin_user_id: userId,
|
||||
target_user_id: userId,
|
||||
action: 'email_change_initiated',
|
||||
details: {
|
||||
old_email: currentEmail,
|
||||
new_email: data.newEmail,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
}).then(({ error }) => {
|
||||
if (error) {
|
||||
handleNonCriticalError(error, {
|
||||
action: 'Log email change audit',
|
||||
userId,
|
||||
metadata: {
|
||||
oldEmail: currentEmail,
|
||||
newEmail: data.newEmail,
|
||||
auditType: 'email_change_initiated'
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Step 6: Send security notifications (non-blocking)
|
||||
if (notificationService.isEnabled()) {
|
||||
notificationService.trigger({
|
||||
workflowId: 'security-alert',
|
||||
subscriberId: userId,
|
||||
payload: {
|
||||
alert_type: 'email_change_initiated',
|
||||
old_email: currentEmail,
|
||||
new_email: data.newEmail,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
}).catch(error => {
|
||||
handleNonCriticalError(error, {
|
||||
action: 'Send email change notification',
|
||||
userId,
|
||||
metadata: {
|
||||
notificationType: 'security-alert',
|
||||
alertType: 'email_change_initiated'
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
handleSuccess(
|
||||
'Email change initiated',
|
||||
'Check both email addresses for confirmation links.'
|
||||
);
|
||||
|
||||
setStep('success');
|
||||
} catch (error: unknown) {
|
||||
const errorMsg = getErrorMessage(error);
|
||||
|
||||
const hasMessage = error instanceof Error || (typeof error === 'object' && error !== null && 'message' in error);
|
||||
const hasStatus = typeof error === 'object' && error !== null && 'status' in error;
|
||||
const errorMessage = hasMessage ? (error as { message: string }).message : '';
|
||||
const errorStatus = hasStatus ? (error as { status: number }).status : 0;
|
||||
|
||||
if (errorMessage.includes('rate limit') || errorStatus === 429) {
|
||||
handleError(
|
||||
new AppError(
|
||||
'Please wait a few minutes before trying again.',
|
||||
'RATE_LIMIT',
|
||||
'Too many email change attempts'
|
||||
),
|
||||
{ action: 'Change email', userId, metadata: { currentEmail, newEmail: data.newEmail } }
|
||||
);
|
||||
} else if (errorMessage.includes('Invalid login credentials')) {
|
||||
handleError(
|
||||
new AppError(
|
||||
'The password you entered is incorrect.',
|
||||
'INVALID_PASSWORD',
|
||||
'Incorrect password during email change'
|
||||
),
|
||||
{ action: 'Verify password', userId }
|
||||
);
|
||||
} else {
|
||||
handleError(
|
||||
error,
|
||||
{
|
||||
action: 'Change email',
|
||||
userId,
|
||||
metadata: {
|
||||
currentEmail,
|
||||
newEmail: data.newEmail,
|
||||
errorType: error instanceof Error ? error.constructor.name : 'Unknown'
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Mail className="w-5 h-5" />
|
||||
Change Email Address
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === 'verification' ? (
|
||||
'Enter your current password and new email address to proceed.'
|
||||
) : (
|
||||
'Verification emails have been sent.'
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{step === 'verification' ? (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
Current email: <strong>{currentEmail}</strong>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="currentPassword"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Current Password *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your current password"
|
||||
disabled={loading}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="newEmail"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>New Email Address *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter your new email"
|
||||
disabled={loading}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="confirmEmail"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Confirm New Email *</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Confirm your new email"
|
||||
disabled={loading}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<TurnstileCaptcha
|
||||
key={captchaKey}
|
||||
onSuccess={setCaptchaToken}
|
||||
onError={() => setCaptchaToken('')}
|
||||
onExpire={() => setCaptchaToken('')}
|
||||
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
|
||||
theme={theme === 'dark' ? 'dark' : 'light'}
|
||||
size="normal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={loading} loadingText="Changing Email..." disabled={!captchaToken}>
|
||||
Change Email
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col items-center justify-center text-center space-y-4 py-6">
|
||||
<div className="rounded-full bg-primary/10 p-3">
|
||||
<CheckCircle2 className="w-8 h-8 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold text-lg">Verification Required</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-md">
|
||||
We've sent verification emails to both your current email address (<strong>{currentEmail}</strong>)
|
||||
and your new email address (<strong>{form.getValues('newEmail')}</strong>).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<Mail className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<strong>Important:</strong> You must click the confirmation link in BOTH emails to complete
|
||||
the email change. Your email address will not change until both verifications are confirmed.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={handleClose} className="w-full">
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
193
src-old/components/settings/EmailChangeStatus.tsx
Normal file
193
src-old/components/settings/EmailChangeStatus.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Mail, Info, CheckCircle2, Circle, Loader2 } from 'lucide-react';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
||||
|
||||
interface EmailChangeStatusProps {
|
||||
currentEmail: string;
|
||||
pendingEmail: string;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
type EmailChangeData = {
|
||||
has_pending_change: boolean;
|
||||
current_email?: string;
|
||||
new_email?: string;
|
||||
current_email_verified?: boolean;
|
||||
new_email_verified?: boolean;
|
||||
change_sent_at?: string;
|
||||
};
|
||||
|
||||
export function EmailChangeStatus({
|
||||
currentEmail,
|
||||
pendingEmail,
|
||||
onCancel
|
||||
}: EmailChangeStatusProps) {
|
||||
const [verificationStatus, setVerificationStatus] = useState({
|
||||
oldEmailVerified: false,
|
||||
newEmailVerified: false
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resending, setResending] = useState(false);
|
||||
|
||||
const checkVerificationStatus = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('get_email_change_status');
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const emailData = data as EmailChangeData;
|
||||
|
||||
if (emailData.has_pending_change) {
|
||||
setVerificationStatus({
|
||||
oldEmailVerified: emailData.current_email_verified || false,
|
||||
newEmailVerified: emailData.new_email_verified || false
|
||||
});
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
handleError(error, { action: 'Check verification status' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkVerificationStatus();
|
||||
// Poll every 30 seconds
|
||||
const interval = setInterval(checkVerificationStatus, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
setResending(true);
|
||||
try {
|
||||
const { error } = await supabase.auth.updateUser({
|
||||
email: pendingEmail
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
handleSuccess(
|
||||
'Verification emails resent',
|
||||
'Check your inbox for the verification links.'
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
handleError(error, { action: 'Resend verification emails' });
|
||||
} finally {
|
||||
setResending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verificationProgress =
|
||||
(verificationStatus.oldEmailVerified ? 50 : 0) +
|
||||
(verificationStatus.newEmailVerified ? 50 : 0);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="border-blue-500/30">
|
||||
<CardContent className="flex items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="border-blue-500/30">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-blue-500" />
|
||||
Email Change in Progress
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Alert>
|
||||
<Info className="w-4 h-4" />
|
||||
<AlertDescription>
|
||||
To complete your email change, both emails must be verified.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{verificationStatus.oldEmailVerified ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-5 h-5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium">Current Email</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{currentEmail}</p>
|
||||
</div>
|
||||
{verificationStatus.oldEmailVerified && (
|
||||
<Badge variant="secondary" className="bg-green-500/10 text-green-500">
|
||||
Verified
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{verificationStatus.newEmailVerified ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500 flex-shrink-0" />
|
||||
) : (
|
||||
<Circle className="w-5 h-5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium">New Email</p>
|
||||
<p className="text-sm text-muted-foreground truncate">{pendingEmail}</p>
|
||||
</div>
|
||||
{verificationStatus.newEmailVerified && (
|
||||
<Badge variant="secondary" className="bg-green-500/10 text-green-500">
|
||||
Verified
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleResendVerification}
|
||||
disabled={resending}
|
||||
className="flex-1"
|
||||
>
|
||||
{resending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Resending...
|
||||
</>
|
||||
) : (
|
||||
'Resend Verification Emails'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={onCancel}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
Cancel Change
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Progress</span>
|
||||
<span className="font-medium">{verificationProgress}%</span>
|
||||
</div>
|
||||
<Progress value={verificationProgress} className="h-2" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
509
src-old/components/settings/LocationTab.tsx
Normal file
509
src-old/components/settings/LocationTab.tsx
Normal file
@@ -0,0 +1,509 @@
|
||||
import { useState, useEffect } 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 { Label } from '@/components/ui/label';
|
||||
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 { Switch } from '@/components/ui/switch';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useProfile } from '@/hooks/useProfile';
|
||||
import { useUnitPreferences } from '@/hooks/useUnitPreferences';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { handleError, handleSuccess, handleNonCriticalError, AppError } from '@/lib/errorHandler';
|
||||
import { MapPin, Calendar, Accessibility, Ruler } from 'lucide-react';
|
||||
import type { LocationFormData, AccessibilityOptions, ParkOption } from '@/types/location';
|
||||
import {
|
||||
locationFormSchema,
|
||||
accessibilityOptionsSchema,
|
||||
parkOptionSchema,
|
||||
DEFAULT_ACCESSIBILITY_OPTIONS,
|
||||
COMMON_TIMEZONES
|
||||
} from '@/lib/locationValidation';
|
||||
|
||||
export function LocationTab() {
|
||||
const { user } = useAuth();
|
||||
const { data: profile, refreshProfile } = useProfile(user?.id);
|
||||
const { preferences: unitPreferences, updatePreferences: updateUnitPreferences } = useUnitPreferences();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [parks, setParks] = useState<ParkOption[]>([]);
|
||||
const [accessibility, setAccessibility] = useState<AccessibilityOptions>(DEFAULT_ACCESSIBILITY_OPTIONS);
|
||||
|
||||
const form = useForm<LocationFormData>({
|
||||
resolver: zodResolver(locationFormSchema),
|
||||
defaultValues: {
|
||||
preferred_pronouns: profile?.preferred_pronouns || null,
|
||||
timezone: profile?.timezone || 'UTC',
|
||||
preferred_language: profile?.preferred_language || 'en',
|
||||
personal_location: profile?.personal_location || null,
|
||||
home_park_id: profile?.home_park_id || null
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (user && profile) {
|
||||
form.reset({
|
||||
preferred_pronouns: profile.preferred_pronouns || null,
|
||||
timezone: profile.timezone || 'UTC',
|
||||
preferred_language: profile.preferred_language || 'en',
|
||||
personal_location: profile.personal_location || null,
|
||||
home_park_id: profile.home_park_id || null
|
||||
});
|
||||
}
|
||||
}, [profile, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
fetchParks();
|
||||
fetchAccessibilityPreferences();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const fetchParks = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('parks')
|
||||
.select('id, name, locations(city, state_province, country)')
|
||||
.order('name');
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const validatedParks = (data || [])
|
||||
.map(park => {
|
||||
try {
|
||||
return parkOptionSchema.parse({
|
||||
id: park.id,
|
||||
name: park.name,
|
||||
location: park.locations ? {
|
||||
city: park.locations.city,
|
||||
state_province: park.locations.state_province,
|
||||
country: park.locations.country
|
||||
} : undefined
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((park): park is ParkOption => park !== null);
|
||||
|
||||
setParks(validatedParks);
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Load parks list',
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAccessibilityPreferences = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('user_preferences')
|
||||
.select('accessibility_options')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (data?.accessibility_options) {
|
||||
const validated = accessibilityOptionsSchema.parse(data.accessibility_options);
|
||||
setAccessibility(validated);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Load accessibility preferences',
|
||||
userId: user.id
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: LocationFormData) => {
|
||||
if (!user) return;
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const validatedData = locationFormSchema.parse(data);
|
||||
const validatedAccessibility = accessibilityOptionsSchema.parse(accessibility);
|
||||
|
||||
const previousProfile = {
|
||||
personal_location: profile?.personal_location,
|
||||
home_park_id: profile?.home_park_id,
|
||||
timezone: profile?.timezone,
|
||||
preferred_language: profile?.preferred_language,
|
||||
preferred_pronouns: profile?.preferred_pronouns
|
||||
};
|
||||
|
||||
const { error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
preferred_pronouns: validatedData.preferred_pronouns || null,
|
||||
timezone: validatedData.timezone,
|
||||
preferred_language: validatedData.preferred_language,
|
||||
personal_location: validatedData.personal_location || null,
|
||||
home_park_id: validatedData.home_park_id || null,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (profileError) {
|
||||
throw profileError;
|
||||
}
|
||||
|
||||
const { error: accessibilityError } = await supabase
|
||||
.from('user_preferences')
|
||||
.update({
|
||||
accessibility_options: validatedAccessibility,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (accessibilityError) {
|
||||
throw accessibilityError;
|
||||
}
|
||||
|
||||
await updateUnitPreferences(unitPreferences);
|
||||
|
||||
await supabase.from('profile_audit_log').insert([{
|
||||
user_id: user.id,
|
||||
changed_by: user.id,
|
||||
action: 'location_info_updated',
|
||||
changes: JSON.parse(JSON.stringify({
|
||||
previous: {
|
||||
profile: previousProfile,
|
||||
accessibility: DEFAULT_ACCESSIBILITY_OPTIONS
|
||||
},
|
||||
updated: {
|
||||
profile: validatedData,
|
||||
accessibility: validatedAccessibility
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
}))
|
||||
}]);
|
||||
|
||||
await refreshProfile();
|
||||
|
||||
handleSuccess(
|
||||
'Settings saved',
|
||||
'Your location, personal information, accessibility, and unit preferences have been updated.'
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof z.ZodError) {
|
||||
handleError(
|
||||
new AppError(
|
||||
'Invalid settings',
|
||||
'VALIDATION_ERROR',
|
||||
error.issues.map(i => i.message).join(', ')
|
||||
),
|
||||
{ action: 'Validate location settings', userId: user.id }
|
||||
);
|
||||
} else {
|
||||
handleError(error, {
|
||||
action: 'Save location settings',
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateAccessibility = (key: keyof AccessibilityOptions, value: any) => {
|
||||
setAccessibility(prev => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-full max-w-md" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Location Settings + Personal Information Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Location Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5" />
|
||||
<CardTitle>Location Settings</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Set your location for better personalized content and timezone display.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="personal_location">Your Location</Label>
|
||||
<Input
|
||||
id="personal_location"
|
||||
{...form.register('personal_location')}
|
||||
placeholder="e.g., San Francisco, CA or Berlin, Germany"
|
||||
/>
|
||||
{form.formState.errors.personal_location && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.personal_location.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your personal location (optional, displayed as text)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="home_park_id">Home Park</Label>
|
||||
<Select
|
||||
value={form.watch('home_park_id') || undefined}
|
||||
onValueChange={value => form.setValue('home_park_id', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select your home park" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{parks.map(park => (
|
||||
<SelectItem key={park.id} value={park.id}>
|
||||
{park.name}
|
||||
{park.location && (
|
||||
<>
|
||||
{park.location.city && `, ${park.location.city}`}
|
||||
{park.location.state_province && `, ${park.location.state_province}`}
|
||||
{`, ${park.location.country}`}
|
||||
</>
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.home_park_id && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.home_park_id.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The theme park you visit most often or consider your "home" park
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timezone">Timezone</Label>
|
||||
<Select
|
||||
value={form.watch('timezone')}
|
||||
onValueChange={value => form.setValue('timezone', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{COMMON_TIMEZONES.map(tz => (
|
||||
<SelectItem key={tz} value={tz}>
|
||||
{tz}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.timezone && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.timezone.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Used to display dates and times in your local timezone.
|
||||
</p>
|
||||
</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>
|
||||
<SelectItem value="pt">Português</SelectItem>
|
||||
<SelectItem value="ja">日本語</SelectItem>
|
||||
<SelectItem value="zh">中文</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.preferred_language && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.preferred_language.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Personal Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5" />
|
||||
<CardTitle>Personal Information</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Optional personal information that can be displayed on your profile.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-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"
|
||||
/>
|
||||
{form.formState.errors.preferred_pronouns && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.preferred_pronouns.message}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
How you'd like others to refer to you.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Unit Preferences + Accessibility Options Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Unit Preferences */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Ruler className="w-5 h-5" />
|
||||
<CardTitle>Units & Measurements</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Choose your preferred measurement system for displaying distances, speeds, and other measurements.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label>Measurement System</Label>
|
||||
<Select
|
||||
value={unitPreferences.measurement_system}
|
||||
onValueChange={(value: 'metric' | 'imperial') =>
|
||||
updateUnitPreferences({ measurement_system: value })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="metric">Metric (km/h, meters, cm)</SelectItem>
|
||||
<SelectItem value="imperial">Imperial (mph, feet, inches)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
All measurements in the database are stored in metric and converted for display.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Accessibility Options */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Accessibility className="w-5 h-5" />
|
||||
<CardTitle>Accessibility Options</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Customize the interface to meet your accessibility needs.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label>Font Size</Label>
|
||||
<Select
|
||||
value={accessibility.font_size}
|
||||
onValueChange={(value: 'small' | 'medium' | 'large') =>
|
||||
updateAccessibility('font_size', value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="small">Small</SelectItem>
|
||||
<SelectItem value="medium">Medium (Default)</SelectItem>
|
||||
<SelectItem value="large">Large</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>High Contrast</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Increase contrast for better visibility
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={accessibility.high_contrast}
|
||||
onCheckedChange={checked => updateAccessibility('high_contrast', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Reduced Motion</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Minimize animations and transitions
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={accessibility.reduced_motion}
|
||||
onCheckedChange={checked => updateAccessibility('reduced_motion', checked)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={saving} loadingText="Saving...">
|
||||
Save Settings
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
371
src-old/components/settings/NotificationsTab.tsx
Normal file
371
src-old/components/settings/NotificationsTab.tsx
Normal file
@@ -0,0 +1,371 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { usePublicNovuSettings } from "@/hooks/usePublicNovuSettings";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { handleError, handleSuccess, handleInfo, handleNonCriticalError } from "@/lib/errorHandler";
|
||||
import { notificationService } from "@/lib/notificationService";
|
||||
import type {
|
||||
NotificationPreferences,
|
||||
NotificationTemplate,
|
||||
ChannelPreferences,
|
||||
WorkflowPreferences,
|
||||
FrequencySettings
|
||||
} from "@/types/notifications";
|
||||
import { DEFAULT_NOTIFICATION_PREFERENCES } from "@/lib/notificationValidation";
|
||||
|
||||
export function NotificationsTab() {
|
||||
const { user } = useAuth();
|
||||
const { isEnabled: isNovuEnabled, isLoading: isNovuLoading } = usePublicNovuSettings();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [templates, setTemplates] = useState<NotificationTemplate[]>([]);
|
||||
const [channelPreferences, setChannelPreferences] = useState<ChannelPreferences>(
|
||||
DEFAULT_NOTIFICATION_PREFERENCES.channelPreferences
|
||||
);
|
||||
const [workflowPreferences, setWorkflowPreferences] = useState<WorkflowPreferences>({});
|
||||
const [frequencySettings, setFrequencySettings] = useState<FrequencySettings>(
|
||||
DEFAULT_NOTIFICATION_PREFERENCES.frequencySettings
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
loadPreferences();
|
||||
loadTemplates();
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const loadPreferences = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const preferences = await notificationService.getPreferences(user.id);
|
||||
|
||||
if (preferences) {
|
||||
setChannelPreferences(preferences.channelPreferences);
|
||||
setWorkflowPreferences(preferences.workflowPreferences);
|
||||
setFrequencySettings(preferences.frequencySettings);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Load notification preferences',
|
||||
userId: user.id
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTemplates = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const templateData = await notificationService.getTemplates();
|
||||
setTemplates(templateData);
|
||||
|
||||
// Initialize workflow preferences for new templates
|
||||
const initialPrefs: WorkflowPreferences = {};
|
||||
templateData.forEach((template) => {
|
||||
if (!(template.workflow_id in workflowPreferences)) {
|
||||
initialPrefs[template.workflow_id] = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(initialPrefs).length > 0) {
|
||||
setWorkflowPreferences((prev) => ({ ...prev, ...initialPrefs }));
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
handleNonCriticalError(error, {
|
||||
action: 'Load notification templates',
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const savePreferences = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const preferences: NotificationPreferences = {
|
||||
channelPreferences,
|
||||
workflowPreferences,
|
||||
frequencySettings
|
||||
};
|
||||
|
||||
const result = await notificationService.updatePreferences(user.id, preferences);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Failed to save notification preferences');
|
||||
}
|
||||
|
||||
handleSuccess(
|
||||
'Notification preferences saved',
|
||||
'Your notification settings have been updated successfully.'
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Save notification preferences',
|
||||
userId: user.id
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateChannelPreference = (channel: keyof ChannelPreferences, value: boolean) => {
|
||||
setChannelPreferences((prev) => ({ ...prev, [channel]: value }));
|
||||
};
|
||||
|
||||
const updateWorkflowPreference = (workflowId: string, value: boolean) => {
|
||||
setWorkflowPreferences((prev) => ({ ...prev, [workflowId]: value }));
|
||||
};
|
||||
|
||||
const requestPushPermission = async () => {
|
||||
if (!('Notification' in window)) {
|
||||
handleInfo(
|
||||
'Push notifications not supported',
|
||||
'Your browser does not support push notifications.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
|
||||
if (permission === 'granted') {
|
||||
updateChannelPreference('push', true);
|
||||
handleSuccess('Push notifications enabled', 'You will now receive push notifications.');
|
||||
} else {
|
||||
handleInfo(
|
||||
'Permission denied',
|
||||
'Push notifications were not enabled. You can change this in your browser settings.'
|
||||
);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Enable push notifications',
|
||||
userId: user?.id
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const groupedTemplates = templates.reduce((acc, template) => {
|
||||
if (!acc[template.category]) {
|
||||
acc[template.category] = [];
|
||||
}
|
||||
acc[template.category].push(template);
|
||||
return acc;
|
||||
}, {} as Record<string, NotificationTemplate[]>);
|
||||
|
||||
if (loading || isNovuLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
<Skeleton className="h-4 w-full max-w-md" />
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
<Skeleton className="h-12 w-full" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{!isNovuEnabled && (
|
||||
<Card className="border-yellow-500/50 bg-yellow-500/10">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-yellow-600 dark:text-yellow-400">
|
||||
Novu Not Configured
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Notifications are not fully configured. Contact an administrator to enable Novu integration.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Notification Channels + Frequency Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Notification Channels */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Channels</CardTitle>
|
||||
<CardDescription>
|
||||
Choose which channels you'd like to receive notifications through
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label className="flex items-center gap-2">
|
||||
In-App Notifications
|
||||
<Badge variant="secondary" className="text-xs">Real-time</Badge>
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive notifications within the application
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={channelPreferences.in_app}
|
||||
onCheckedChange={(checked) => updateChannelPreference('in_app', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Email Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Receive notifications via email
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={channelPreferences.email}
|
||||
onCheckedChange={(checked) => updateChannelPreference('email', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Push Notifications</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Browser push notifications
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={channelPreferences.push}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
requestPushPermission();
|
||||
} else {
|
||||
updateChannelPreference('push', false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Notification Frequency */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notification Frequency</CardTitle>
|
||||
<CardDescription>
|
||||
Control how often you receive notifications
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Digest Frequency</Label>
|
||||
<Select
|
||||
value={frequencySettings.digest}
|
||||
onValueChange={(value: 'realtime' | 'hourly' | 'daily' | 'weekly') =>
|
||||
setFrequencySettings((prev) => ({ ...prev, digest: value }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="realtime">Real-time</SelectItem>
|
||||
<SelectItem value="hourly">Hourly</SelectItem>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
<SelectItem value="weekly">Weekly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Group notifications and send them in batches
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Maximum Notifications Per Hour</Label>
|
||||
<Select
|
||||
value={frequencySettings.max_per_hour.toString()}
|
||||
onValueChange={(value) =>
|
||||
setFrequencySettings((prev) => ({ ...prev, max_per_hour: parseInt(value) }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5">5 per hour</SelectItem>
|
||||
<SelectItem value="10">10 per hour</SelectItem>
|
||||
<SelectItem value="20">20 per hour</SelectItem>
|
||||
<SelectItem value="50">50 per hour</SelectItem>
|
||||
<SelectItem value="999">Unlimited</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Limit the number of notifications you receive per hour
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Workflow Preferences - Full Width */}
|
||||
{Object.keys(groupedTemplates).map((category) => (
|
||||
<Card key={category}>
|
||||
<CardHeader>
|
||||
<CardTitle className="capitalize">{category} Notifications</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your {category} notification preferences
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{groupedTemplates[category].map((template, index) => (
|
||||
<div key={template.id}>
|
||||
{index > 0 && <Separator className="my-4" />}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>{template.name}</Label>
|
||||
{template.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{template.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={workflowPreferences[template.workflow_id] ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
updateWorkflowPreference(template.workflow_id, checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Button
|
||||
onClick={savePreferences}
|
||||
className="w-full"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Notification Preferences'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
483
src-old/components/settings/PasswordUpdateDialog.tsx
Normal file
483
src-old/components/settings/PasswordUpdateDialog.tsx
Normal file
@@ -0,0 +1,483 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { invokeWithTracking } from '@/lib/edgeFunctionTracking';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { handleError, handleSuccess, handleNonCriticalError, AppError, getErrorMessage } from '@/lib/errorHandler';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { Loader2, Shield, CheckCircle2 } from 'lucide-react';
|
||||
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
|
||||
import { TurnstileCaptcha } from '@/components/auth/TurnstileCaptcha';
|
||||
import { useTheme } from '@/components/theme/ThemeProvider';
|
||||
import { passwordSchema } from '@/lib/validation';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
type PasswordFormData = z.infer<typeof passwordSchema>;
|
||||
|
||||
interface PasswordUpdateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
type Step = 'password' | 'mfa' | 'success';
|
||||
|
||||
interface ErrorWithCode {
|
||||
code?: string;
|
||||
status?: number;
|
||||
}
|
||||
|
||||
function isErrorWithCode(error: unknown): error is Error & ErrorWithCode {
|
||||
return error instanceof Error && ('code' in error || 'status' in error);
|
||||
}
|
||||
|
||||
export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: PasswordUpdateDialogProps) {
|
||||
const { theme } = useTheme();
|
||||
const [step, setStep] = useState<Step>('password');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [nonce, setNonce] = useState<string>('');
|
||||
const [newPassword, setNewPassword] = useState<string>('');
|
||||
const [hasMFA, setHasMFA] = useState(false);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [captchaToken, setCaptchaToken] = useState<string>('');
|
||||
const [captchaKey, setCaptchaKey] = useState(0);
|
||||
const [userId, setUserId] = useState<string>('');
|
||||
|
||||
const form = useForm<PasswordFormData>({
|
||||
resolver: zodResolver(passwordSchema),
|
||||
defaultValues: {
|
||||
currentPassword: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
}
|
||||
});
|
||||
|
||||
// Check if user has MFA enabled and get user ID
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
checkMFAStatus();
|
||||
supabase.auth.getUser().then(({ data }) => {
|
||||
if (data.user) setUserId(data.user.id);
|
||||
});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const checkMFAStatus = async () => {
|
||||
try {
|
||||
const { data, error } = await supabase.auth.mfa.listFactors();
|
||||
if (error) throw error;
|
||||
|
||||
const hasVerifiedTotp = data?.totp?.some(factor => factor.status === 'verified') || false;
|
||||
setHasMFA(hasVerifiedTotp);
|
||||
} catch (error: unknown) {
|
||||
handleNonCriticalError(error, {
|
||||
action: 'Check MFA status',
|
||||
userId,
|
||||
metadata: { context: 'password_change_dialog' }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: PasswordFormData) => {
|
||||
if (!captchaToken) {
|
||||
handleError(
|
||||
new AppError('Please complete the CAPTCHA verification.', 'CAPTCHA_REQUIRED'),
|
||||
{
|
||||
action: 'Change password',
|
||||
userId,
|
||||
metadata: { step: 'captcha_validation' }
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Phase 4: AAL2 check for security-critical operations
|
||||
if (hasMFA) {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (session) {
|
||||
const jwt = session.access_token;
|
||||
const payload = JSON.parse(atob(jwt.split('.')[1]));
|
||||
const currentAal = payload.aal || 'aal1';
|
||||
|
||||
if (currentAal !== 'aal2') {
|
||||
handleError(
|
||||
new AppError(
|
||||
'Please verify your identity with MFA first',
|
||||
'AAL2_REQUIRED'
|
||||
),
|
||||
{ action: 'Change password', userId, metadata: { step: 'aal2_check' } }
|
||||
);
|
||||
sessionStorage.setItem('mfa_step_up_required', 'true');
|
||||
sessionStorage.setItem('mfa_intended_path', '/settings?tab=security');
|
||||
window.location.href = '/auth';
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Step 1: Reauthenticate with current password to get a nonce
|
||||
const { error: signInError } = await supabase.auth.signInWithPassword({
|
||||
email: (await supabase.auth.getUser()).data.user?.email || '',
|
||||
password: data.currentPassword,
|
||||
options: {
|
||||
captchaToken
|
||||
}
|
||||
});
|
||||
|
||||
if (signInError) {
|
||||
// Reset CAPTCHA on authentication failure
|
||||
setCaptchaToken('');
|
||||
setCaptchaKey(prev => prev + 1);
|
||||
|
||||
throw signInError;
|
||||
}
|
||||
|
||||
// Step 2: Generate nonce for secure password update
|
||||
const { data: sessionData } = await supabase.auth.getSession();
|
||||
|
||||
if (!sessionData.session) throw new Error('No active session');
|
||||
|
||||
const generatedNonce = sessionData.session.access_token.substring(0, 32);
|
||||
setNonce(generatedNonce);
|
||||
setNewPassword(data.newPassword);
|
||||
|
||||
// If user has MFA, require TOTP verification
|
||||
if (hasMFA) {
|
||||
setStep('mfa');
|
||||
} else {
|
||||
// No MFA, proceed with password update
|
||||
await updatePasswordWithNonce(data.newPassword, generatedNonce);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const errorMessage = getErrorMessage(error);
|
||||
const errorStatus = isErrorWithCode(error) ? error.status : undefined;
|
||||
|
||||
if (errorMessage?.includes('rate limit') || errorStatus === 429) {
|
||||
handleError(
|
||||
new AppError(
|
||||
'Please wait a few minutes before trying again.',
|
||||
'RATE_LIMIT',
|
||||
'Too many password change attempts'
|
||||
),
|
||||
{ action: 'Change password', userId, metadata: { step: 'authentication' } }
|
||||
);
|
||||
} else if (errorMessage?.includes('Invalid login credentials')) {
|
||||
handleError(
|
||||
new AppError(
|
||||
'The password you entered is incorrect.',
|
||||
'INVALID_PASSWORD',
|
||||
'Incorrect current password'
|
||||
),
|
||||
{ action: 'Verify password', userId }
|
||||
);
|
||||
} else {
|
||||
handleError(error, {
|
||||
action: 'Change password',
|
||||
userId,
|
||||
metadata: { step: 'authentication' }
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyMFAAndUpdate = async () => {
|
||||
if (totpCode.length !== 6) {
|
||||
handleError(
|
||||
new AppError('Please enter a valid 6-digit code', 'INVALID_MFA_CODE'),
|
||||
{ action: 'Verify MFA', userId, metadata: { step: 'mfa_verification' } }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Get the factor ID first
|
||||
const factorId = (await supabase.auth.mfa.listFactors()).data?.totp?.[0]?.id || '';
|
||||
|
||||
if (!factorId) {
|
||||
throw new Error('No MFA factor found');
|
||||
}
|
||||
|
||||
// Create challenge
|
||||
const { data: challengeData, error: challengeError } = await supabase.auth.mfa.challenge({
|
||||
factorId
|
||||
});
|
||||
|
||||
if (challengeError) {
|
||||
throw challengeError;
|
||||
}
|
||||
|
||||
// Verify TOTP code with correct factorId
|
||||
const { error: verifyError } = await supabase.auth.mfa.verify({
|
||||
factorId,
|
||||
challengeId: challengeData.id,
|
||||
code: totpCode
|
||||
});
|
||||
|
||||
if (verifyError) {
|
||||
throw verifyError;
|
||||
}
|
||||
|
||||
// TOTP verified, now update password
|
||||
await updatePasswordWithNonce(newPassword, nonce);
|
||||
} catch (error: unknown) {
|
||||
handleError(
|
||||
new AppError(
|
||||
getErrorMessage(error) || 'Invalid authentication code',
|
||||
'MFA_VERIFICATION_FAILED',
|
||||
'TOTP code verification failed'
|
||||
),
|
||||
{ action: 'Verify MFA', userId, metadata: { step: 'mfa_verification' } }
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updatePasswordWithNonce = async (password: string, nonceValue: string) => {
|
||||
try {
|
||||
// Step 2: Update password
|
||||
const { error: updateError } = await supabase.auth.updateUser({
|
||||
password
|
||||
});
|
||||
|
||||
if (updateError) throw updateError;
|
||||
|
||||
// Step 3: Log audit trail
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (user) {
|
||||
await supabase.from('admin_audit_log').insert({
|
||||
admin_user_id: user.id,
|
||||
target_user_id: user.id,
|
||||
action: 'password_changed',
|
||||
details: {
|
||||
timestamp: new Date().toISOString(),
|
||||
method: hasMFA ? 'password_with_mfa' : 'password_only',
|
||||
user_agent: navigator.userAgent
|
||||
}
|
||||
});
|
||||
|
||||
// Step 4: Send security notification
|
||||
try {
|
||||
await invokeWithTracking(
|
||||
'trigger-notification',
|
||||
{
|
||||
workflowId: 'security-alert',
|
||||
subscriberId: user.id,
|
||||
payload: {
|
||||
alert_type: 'password_changed',
|
||||
timestamp: new Date().toISOString(),
|
||||
device: navigator.userAgent.split(' ')[0]
|
||||
}
|
||||
},
|
||||
user.id
|
||||
);
|
||||
} catch (notifError) {
|
||||
handleNonCriticalError(notifError, {
|
||||
action: 'Send password change notification',
|
||||
userId: user!.id,
|
||||
metadata: { context: 'post_password_change' }
|
||||
});
|
||||
// Don't fail the password update if notification fails
|
||||
}
|
||||
}
|
||||
|
||||
setStep('success');
|
||||
form.reset();
|
||||
|
||||
// Auto-close after 2 seconds
|
||||
setTimeout(() => {
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
setStep('password');
|
||||
setTotpCode('');
|
||||
}, 2000);
|
||||
} catch (error: unknown) {
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (!loading) {
|
||||
onOpenChange(false);
|
||||
setStep('password');
|
||||
form.reset();
|
||||
setTotpCode('');
|
||||
setCaptchaToken('');
|
||||
setCaptchaKey(prev => prev + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
{step === 'password' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change Password</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter your current password and choose a new one. {hasMFA && 'You will need to verify with your authenticator app.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="currentPassword">Current Password</Label>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
{...form.register('currentPassword')}
|
||||
placeholder="Enter your current password"
|
||||
disabled={loading}
|
||||
/>
|
||||
{form.formState.errors.currentPassword && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.currentPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="newPassword">New Password</Label>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
{...form.register('newPassword')}
|
||||
placeholder="Enter your new password"
|
||||
disabled={loading}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Must be 8+ characters with uppercase, lowercase, number, and special character
|
||||
</p>
|
||||
{form.formState.errors.newPassword && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.newPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirmPassword">Confirm New Password</Label>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
{...form.register('confirmPassword')}
|
||||
placeholder="Confirm your new password"
|
||||
disabled={loading}
|
||||
/>
|
||||
{form.formState.errors.confirmPassword && (
|
||||
<p className="text-sm text-destructive">
|
||||
{form.formState.errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<TurnstileCaptcha
|
||||
key={captchaKey}
|
||||
onSuccess={setCaptchaToken}
|
||||
onError={() => setCaptchaToken('')}
|
||||
onExpire={() => setCaptchaToken('')}
|
||||
siteKey={import.meta.env.VITE_TURNSTILE_SITE_KEY}
|
||||
theme={theme === 'dark' ? 'dark' : 'light'}
|
||||
size="normal"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose} disabled={loading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={loading || !captchaToken}>
|
||||
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{hasMFA ? 'Continue' : 'Update Password'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'mfa' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Verify Your Identity
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter the 6-digit code from your authenticator app to complete the password change.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Label htmlFor="totp-code">Code from Authenticator App</Label>
|
||||
<InputOTP
|
||||
maxLength={6}
|
||||
value={totpCode}
|
||||
onChange={setTotpCode}
|
||||
disabled={loading}
|
||||
>
|
||||
<InputOTPGroup>
|
||||
<InputOTPSlot index={0} />
|
||||
<InputOTPSlot index={1} />
|
||||
<InputOTPSlot index={2} />
|
||||
<InputOTPSlot index={3} />
|
||||
<InputOTPSlot index={4} />
|
||||
<InputOTPSlot index={5} />
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setStep('password');
|
||||
setTotpCode('');
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button onClick={verifyMFAAndUpdate} loading={loading} loadingText="Verifying..." disabled={totpCode.length !== 6}>
|
||||
Verify & Update
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === 'success' && (
|
||||
<>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-green-600">
|
||||
<CheckCircle2 className="h-5 w-5" />
|
||||
Password Updated
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your password has been successfully changed. A security notification has been sent to your email.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
412
src-old/components/settings/PrivacyTab.tsx
Normal file
412
src-old/components/settings/PrivacyTab.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
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 { handleError, handleSuccess, AppError } from '@/lib/errorHandler';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useProfile } from '@/hooks/useProfile';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { Eye, UserX, Shield, Search } from 'lucide-react';
|
||||
import { BlockedUsers } from '@/components/privacy/BlockedUsers';
|
||||
import type { PrivacySettings, PrivacyFormData } from '@/types/privacy';
|
||||
import { privacyFormSchema, privacySettingsSchema, DEFAULT_PRIVACY_SETTINGS } from '@/lib/privacyValidation';
|
||||
import { z } from 'zod';
|
||||
|
||||
export function PrivacyTab() {
|
||||
const { user } = useAuth();
|
||||
const { data: profile, refreshProfile } = useProfile(user?.id);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [preferences, setPreferences] = useState<PrivacySettings | null>(null);
|
||||
|
||||
const form = useForm<PrivacyFormData>({
|
||||
resolver: zodResolver(privacyFormSchema),
|
||||
defaultValues: {
|
||||
privacy_level: (profile?.privacy_level === 'public' || profile?.privacy_level === 'private')
|
||||
? profile.privacy_level
|
||||
: 'public',
|
||||
show_pronouns: profile?.show_pronouns || false,
|
||||
...DEFAULT_PRIVACY_SETTINGS
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchPreferences();
|
||||
}, [user]);
|
||||
|
||||
const fetchPreferences = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('user_preferences')
|
||||
.select('privacy_settings')
|
||||
.eq('user_id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (error && error.code !== 'PGRST116') {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (data?.privacy_settings) {
|
||||
const parseResult = privacySettingsSchema.safeParse(data.privacy_settings);
|
||||
|
||||
if (parseResult.success) {
|
||||
setPreferences(parseResult.data);
|
||||
form.reset({
|
||||
privacy_level: (profile?.privacy_level === 'public' || profile?.privacy_level === 'private')
|
||||
? profile.privacy_level
|
||||
: 'public',
|
||||
show_pronouns: profile?.show_pronouns || false,
|
||||
...parseResult.data
|
||||
});
|
||||
} else {
|
||||
await initializePreferences();
|
||||
}
|
||||
} else {
|
||||
await initializePreferences();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Load privacy settings',
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const initializePreferences = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('user_preferences')
|
||||
.insert([{
|
||||
user_id: user.id,
|
||||
privacy_settings: DEFAULT_PRIVACY_SETTINGS
|
||||
}]);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setPreferences(DEFAULT_PRIVACY_SETTINGS);
|
||||
form.reset({
|
||||
privacy_level: (profile?.privacy_level === 'public' || profile?.privacy_level === 'private')
|
||||
? profile.privacy_level
|
||||
: 'public',
|
||||
show_pronouns: profile?.show_pronouns || false,
|
||||
...DEFAULT_PRIVACY_SETTINGS
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Initialize privacy settings',
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = async (data: PrivacyFormData) => {
|
||||
if (!user) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Validate the form data
|
||||
const validated = privacyFormSchema.parse(data);
|
||||
|
||||
// Update profile privacy settings
|
||||
const { error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
privacy_level: validated.privacy_level,
|
||||
show_pronouns: validated.show_pronouns,
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.eq('user_id', user.id);
|
||||
|
||||
if (profileError) {
|
||||
throw profileError;
|
||||
}
|
||||
|
||||
// Extract privacy settings (exclude profile fields)
|
||||
const { privacy_level, show_pronouns, ...privacySettings } = validated;
|
||||
|
||||
// Update user preferences
|
||||
const { error: prefsError } = await supabase
|
||||
.from('user_preferences')
|
||||
.upsert([{
|
||||
user_id: user.id,
|
||||
privacy_settings: privacySettings,
|
||||
updated_at: new Date().toISOString()
|
||||
}]);
|
||||
|
||||
if (prefsError) {
|
||||
throw prefsError;
|
||||
}
|
||||
|
||||
// Log to audit trail
|
||||
await supabase.from('profile_audit_log').insert([{
|
||||
user_id: user.id,
|
||||
changed_by: user.id,
|
||||
action: 'privacy_settings_updated',
|
||||
changes: JSON.parse(JSON.stringify({
|
||||
previous: preferences,
|
||||
updated: privacySettings,
|
||||
timestamp: new Date().toISOString()
|
||||
}))
|
||||
}]);
|
||||
|
||||
await refreshProfile();
|
||||
setPreferences(privacySettings);
|
||||
|
||||
handleSuccess(
|
||||
'Privacy settings updated',
|
||||
'Your privacy preferences have been successfully saved.'
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof z.ZodError) {
|
||||
handleError(
|
||||
new AppError(
|
||||
'Invalid privacy settings',
|
||||
'VALIDATION_ERROR',
|
||||
error.issues.map(e => e.message).join(', ')
|
||||
),
|
||||
{ action: 'Validate privacy settings', userId: user.id }
|
||||
);
|
||||
} else {
|
||||
handleError(error, {
|
||||
action: 'Update privacy settings',
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
{/* Profile Visibility + Activity & Content Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Profile Visibility */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="w-5 h-5" />
|
||||
<CardTitle>Profile Visibility</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Control who can see your profile and personal information.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="privacy_level">Profile Privacy</Label>
|
||||
<Select
|
||||
value={form.watch('privacy_level')}
|
||||
onValueChange={(value: 'public' | 'private') => form.setValue('privacy_level', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="public">
|
||||
Public - Anyone can view your profile
|
||||
</SelectItem>
|
||||
<SelectItem value="private">
|
||||
Private - Only you can view your profile
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Show Pronouns</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display your preferred pronouns on your profile
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.watch('show_pronouns')}
|
||||
onCheckedChange={checked => form.setValue('show_pronouns', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Show Location</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display your location on your profile
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.watch('show_location')}
|
||||
onCheckedChange={checked => form.setValue('show_location', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Show Age/Birth Date</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display your birth date on your profile
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.watch('show_age')}
|
||||
onCheckedChange={checked => form.setValue('show_age', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Show Avatar</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display your profile picture
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.watch('show_avatar')}
|
||||
onCheckedChange={checked => form.setValue('show_avatar', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Show Bio</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display your profile bio/description
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.watch('show_bio')}
|
||||
onCheckedChange={checked => form.setValue('show_bio', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Show Activity Statistics</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display your ride counts and park visits
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.watch('show_activity_stats')}
|
||||
onCheckedChange={checked => form.setValue('show_activity_stats', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Show Home Park</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Display your home park preference
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.watch('show_home_park')}
|
||||
onCheckedChange={checked => form.setValue('show_home_park', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Activity & Content */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
<CardTitle>Activity & Content</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Control the visibility of your activities and content.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="activity_visibility">Activity Visibility</Label>
|
||||
<Select
|
||||
value={form.watch('activity_visibility')}
|
||||
onValueChange={(value: 'public' | 'private') => form.setValue('activity_visibility', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="public">
|
||||
Public - Anyone can see your reviews and lists
|
||||
</SelectItem>
|
||||
<SelectItem value="private">
|
||||
Private - Only you can see your activities
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This affects the visibility of your reviews, ride credits, and top lists.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search & Discovery */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="w-5 h-5" />
|
||||
<CardTitle>Search & Discovery</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Control how others can find and discover your profile.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label>Search Visibility</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Allow your profile to appear in search results
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.watch('search_visibility')}
|
||||
onCheckedChange={checked => form.setValue('search_visibility', checked)}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Blocked Users */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserX className="w-5 h-5" />
|
||||
<CardTitle>Blocked Users</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Manage users you have blocked from interacting with you.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BlockedUsers />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={loading} loadingText="Saving...">
|
||||
Save Privacy Settings
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
457
src-old/components/settings/SecurityTab.tsx
Normal file
457
src-old/components/settings/SecurityTab.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { handleError, handleSuccess } from '@/lib/errorHandler';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
import { Shield, Key, Smartphone, Globe, Loader2, Monitor, Tablet, Trash2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { TOTPSetup } from '@/components/auth/TOTPSetup';
|
||||
import { GoogleIcon } from '@/components/icons/GoogleIcon';
|
||||
import { DiscordIcon } from '@/components/icons/DiscordIcon';
|
||||
import { PasswordUpdateDialog } from './PasswordUpdateDialog';
|
||||
import {
|
||||
getUserIdentities,
|
||||
checkDisconnectSafety,
|
||||
disconnectIdentity,
|
||||
connectIdentity,
|
||||
addPasswordToAccount
|
||||
} from '@/lib/identityService';
|
||||
import type { UserIdentity, OAuthProvider } from '@/types/identity';
|
||||
import type { AuthSession } from '@/types/auth';
|
||||
import { supabase } from '@/lib/supabaseClient';
|
||||
import { SessionRevokeConfirmDialog } from './SessionRevokeConfirmDialog';
|
||||
|
||||
export function SecurityTab() {
|
||||
const { user } = useAuth();
|
||||
const { isModerator } = useUserRole();
|
||||
const navigate = useNavigate();
|
||||
const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
|
||||
const [identities, setIdentities] = useState<UserIdentity[]>([]);
|
||||
const [loadingIdentities, setLoadingIdentities] = useState(true);
|
||||
const [disconnectingProvider, setDisconnectingProvider] = useState<OAuthProvider | null>(null);
|
||||
const [hasPassword, setHasPassword] = useState(false);
|
||||
const [addingPassword, setAddingPassword] = useState(false);
|
||||
const [sessions, setSessions] = useState<AuthSession[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(true);
|
||||
const [sessionToRevoke, setSessionToRevoke] = useState<{ id: string; isCurrent: boolean } | null>(null);
|
||||
|
||||
// Load user identities on mount
|
||||
useEffect(() => {
|
||||
loadIdentities();
|
||||
fetchSessions();
|
||||
}, []);
|
||||
|
||||
const loadIdentities = async () => {
|
||||
try {
|
||||
setLoadingIdentities(true);
|
||||
const fetchedIdentities = await getUserIdentities();
|
||||
setIdentities(fetchedIdentities);
|
||||
|
||||
// Check if user has email/password auth
|
||||
const hasEmailProvider = fetchedIdentities.some(i => i.provider === 'email');
|
||||
setHasPassword(hasEmailProvider);
|
||||
} catch (error: unknown) {
|
||||
handleError(error, { action: 'Load connected accounts', userId: user?.id });
|
||||
} finally {
|
||||
setLoadingIdentities(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSocialLogin = async (provider: OAuthProvider) => {
|
||||
try {
|
||||
const result = await connectIdentity(provider, '/settings?tab=security');
|
||||
|
||||
if (!result.success) {
|
||||
handleError(
|
||||
new Error(result.error || 'Connection failed'),
|
||||
{ action: `Connect ${provider} account` }
|
||||
);
|
||||
} else {
|
||||
handleSuccess('Redirecting...', `Connecting your ${provider} account...`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
handleError(error, { action: `Connect ${provider} account` });
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnlinkSocial = async (provider: OAuthProvider) => {
|
||||
// Check if disconnect is safe
|
||||
const safetyCheck = await checkDisconnectSafety(provider);
|
||||
|
||||
if (!safetyCheck.canDisconnect) {
|
||||
if (safetyCheck.reason === 'no_password_backup') {
|
||||
// Trigger password reset flow first
|
||||
await handleAddPassword();
|
||||
handleSuccess(
|
||||
"Password Required First",
|
||||
"Check your email for a password reset link. Once you've set a password, you can disconnect your social login."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (safetyCheck.reason === 'last_identity') {
|
||||
handleError(
|
||||
new Error("You cannot disconnect your only login method"),
|
||||
{ action: "Disconnect social login" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Proceed with disconnect
|
||||
setDisconnectingProvider(provider);
|
||||
const result = await disconnectIdentity(provider);
|
||||
setDisconnectingProvider(null);
|
||||
|
||||
if (result.success) {
|
||||
await loadIdentities(); // Refresh identities list
|
||||
handleSuccess("Disconnected", `Your ${provider} account has been successfully disconnected.`);
|
||||
} else {
|
||||
handleError(
|
||||
new Error(result.error || 'Disconnect failed'),
|
||||
{ action: `Disconnect ${provider} account` }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddPassword = async () => {
|
||||
setAddingPassword(true);
|
||||
|
||||
const result = await addPasswordToAccount();
|
||||
|
||||
if (result.success) {
|
||||
handleSuccess(
|
||||
"Password Setup Email Sent!",
|
||||
`Check ${result.email} for a password reset link. Click it to set your password.`
|
||||
);
|
||||
} else {
|
||||
handleError(
|
||||
new Error(result.error || 'Failed to send email'),
|
||||
{ action: 'Send password setup email' }
|
||||
);
|
||||
}
|
||||
|
||||
setAddingPassword(false);
|
||||
};
|
||||
|
||||
const fetchSessions = async () => {
|
||||
if (!user) return;
|
||||
|
||||
setLoadingSessions(true);
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase.rpc('get_my_sessions');
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setSessions((data as AuthSession[]) || []);
|
||||
} catch (error: unknown) {
|
||||
handleError(error, {
|
||||
action: 'Load active sessions',
|
||||
userId: user.id
|
||||
});
|
||||
setSessions([]);
|
||||
} finally {
|
||||
setLoadingSessions(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initiateSessionRevoke = async (sessionId: string) => {
|
||||
// Get current session to check if revoking self
|
||||
const { data: { session: currentSession } } = await supabase.auth.getSession();
|
||||
const isCurrentSession = currentSession && sessions.some(s =>
|
||||
s.id === sessionId && s.refreshed_at === currentSession.access_token
|
||||
);
|
||||
|
||||
setSessionToRevoke({ id: sessionId, isCurrent: !!isCurrentSession });
|
||||
};
|
||||
|
||||
const confirmRevokeSession = async () => {
|
||||
if (!sessionToRevoke) return;
|
||||
|
||||
const { error } = await supabase.rpc('revoke_my_session', { session_id: sessionToRevoke.id });
|
||||
|
||||
if (error) {
|
||||
handleError(error, { action: 'Revoke session', userId: user?.id });
|
||||
} else {
|
||||
handleSuccess('Success', 'Session revoked successfully');
|
||||
|
||||
if (sessionToRevoke.isCurrent) {
|
||||
// Redirect to login after revoking current session
|
||||
setTimeout(() => {
|
||||
window.location.href = '/auth';
|
||||
}, 1000);
|
||||
} else {
|
||||
fetchSessions();
|
||||
}
|
||||
}
|
||||
|
||||
setSessionToRevoke(null);
|
||||
};
|
||||
|
||||
const getDeviceIcon = (userAgent: string | null) => {
|
||||
if (!userAgent) return <Monitor className="w-4 h-4" />;
|
||||
|
||||
const ua = userAgent.toLowerCase();
|
||||
if (ua.includes('mobile') || ua.includes('android') || ua.includes('iphone')) {
|
||||
return <Smartphone className="w-4 h-4" />;
|
||||
}
|
||||
if (ua.includes('tablet') || ua.includes('ipad')) {
|
||||
return <Tablet className="w-4 h-4" />;
|
||||
}
|
||||
return <Monitor className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
const getBrowserName = (userAgent: string | null) => {
|
||||
if (!userAgent) return 'Unknown Browser';
|
||||
|
||||
const ua = userAgent.toLowerCase();
|
||||
if (ua.includes('firefox')) return 'Firefox';
|
||||
if (ua.includes('chrome') && !ua.includes('edg')) return 'Chrome';
|
||||
if (ua.includes('safari') && !ua.includes('chrome')) return 'Safari';
|
||||
if (ua.includes('edg')) return 'Edge';
|
||||
return 'Unknown Browser';
|
||||
};
|
||||
|
||||
// Get connected accounts with identity data
|
||||
const connectedAccounts = [
|
||||
{
|
||||
provider: 'google' as OAuthProvider,
|
||||
identity: identities.find(i => i.provider === 'google'),
|
||||
icon: <GoogleIcon className="w-5 h-5" />
|
||||
},
|
||||
{
|
||||
provider: 'discord' as OAuthProvider,
|
||||
identity: identities.find(i => i.provider === 'discord'),
|
||||
icon: <DiscordIcon className="w-5 h-5" />
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PasswordUpdateDialog
|
||||
open={passwordDialogOpen}
|
||||
onOpenChange={setPasswordDialogOpen}
|
||||
onSuccess={() => {
|
||||
handleSuccess('Password updated', 'Your password has been successfully changed.');
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Password + Connected Accounts Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Password Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
<CardTitle>{hasPassword ? 'Change Password' : 'Add Password'}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{hasPassword ? (
|
||||
<>Update your password to keep your account secure.</>
|
||||
) : (
|
||||
<>Add password authentication to your account for increased security and backup access.</>
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hasPassword ? (
|
||||
<Button onClick={() => setPasswordDialogOpen(true)}>
|
||||
Change Password
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleAddPassword} disabled={addingPassword}>
|
||||
{addingPassword ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Sending Email...
|
||||
</>
|
||||
) : (
|
||||
'Add Password'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Connected Accounts */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="w-5 h-5" />
|
||||
<CardTitle>Connected Accounts</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Manage your social login connections for easier access to your account.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{loadingIdentities ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
connectedAccounts.map(account => {
|
||||
const isConnected = !!account.identity;
|
||||
const isDisconnecting = disconnectingProvider === account.provider;
|
||||
const email = account.identity?.identity_data?.email;
|
||||
|
||||
return (
|
||||
<div key={account.provider} className="flex items-center justify-between p-4 border rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 bg-muted rounded-full flex items-center justify-center">
|
||||
{account.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium capitalize">{account.provider}</p>
|
||||
{isConnected && email && (
|
||||
<p className="text-sm text-muted-foreground">{email}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Badge variant="secondary">Connected</Badge>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleUnlinkSocial(account.provider)}
|
||||
disabled={isDisconnecting}
|
||||
>
|
||||
{isDisconnecting ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Disconnecting...
|
||||
</>
|
||||
) : (
|
||||
'Disconnect'
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSocialLogin(account.provider)}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Multi-Factor Authentication - Full Width (Moderators+ Only) */}
|
||||
{isModerator() && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Smartphone className="w-5 h-5" />
|
||||
<CardTitle>Multi-Factor Authentication</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Add an extra layer of security to your account with Multi-Factor Authentication
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TOTPSetup />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Active Sessions - Full Width */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-5 h-5" />
|
||||
<CardTitle>Active Sessions {sessions.length > 0 && `(${sessions.length})`}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
Review and manage your active login sessions across all devices
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loadingSessions ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="flex items-start justify-between p-3 border rounded-lg">
|
||||
<div className="flex gap-3 flex-1">
|
||||
<Skeleton className="w-4 h-4 rounded" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-3 w-48" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="w-20 h-8" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : sessions.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{sessions.map((session) => (
|
||||
<div key={session.id} className="flex items-start justify-between p-3 border rounded-lg">
|
||||
<div className="flex gap-3">
|
||||
{getDeviceIcon(session.user_agent)}
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{getBrowserName(session.user_agent)}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{session.ip && (
|
||||
<span title="Last 8 characters of hashed IP address for privacy">
|
||||
{session.ip} •{' '}
|
||||
</span>
|
||||
)}
|
||||
Last active: {format(new Date(session.refreshed_at || session.created_at), 'PPpp')}
|
||||
{session.aal === 'aal2' && ' • Multi-Factor'}
|
||||
</p>
|
||||
{session.not_after && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Expires: {format(new Date(session.not_after), 'PPpp')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => initiateSessionRevoke(session.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Revoke
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-8 text-muted-foreground">
|
||||
<p className="text-sm">No active sessions found</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<SessionRevokeConfirmDialog
|
||||
open={!!sessionToRevoke}
|
||||
onOpenChange={(open) => !open && setSessionToRevoke(null)}
|
||||
onConfirm={confirmRevokeSession}
|
||||
isCurrentSession={sessionToRevoke?.isCurrent ?? false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
53
src-old/components/settings/SessionRevokeConfirmDialog.tsx
Normal file
53
src-old/components/settings/SessionRevokeConfirmDialog.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onConfirm: () => void;
|
||||
isCurrentSession: boolean;
|
||||
}
|
||||
|
||||
export function SessionRevokeConfirmDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
isCurrentSession
|
||||
}: Props) {
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Revoke Session?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{isCurrentSession ? (
|
||||
<>
|
||||
This is your current session. Revoking it will sign you out immediately
|
||||
and you'll need to log in again.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
This will immediately end the selected session. The device using that
|
||||
session will be signed out.
|
||||
</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onConfirm} className="bg-destructive hover:bg-destructive/90">
|
||||
{isCurrentSession ? 'Sign Out' : 'Revoke Session'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
82
src-old/components/settings/SimplePhotoUpload.tsx
Normal file
82
src-old/components/settings/SimplePhotoUpload.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
import { Upload } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
import { getErrorMessage } from '@/lib/errorHandler';
|
||||
|
||||
interface SimplePhotoUploadProps {
|
||||
onUpload: (imageId: string, imageUrl: string) => Promise<void>;
|
||||
disabled?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SimplePhotoUpload({ onUpload, disabled, children }: SimplePhotoUploadProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast({
|
||||
title: 'Invalid file',
|
||||
description: 'Please select an image file',
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast({
|
||||
title: 'File too large',
|
||||
description: 'Please select an image under 5MB',
|
||||
variant: 'destructive'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
// Create a mock upload for now - in real implementation would upload to CloudFlare
|
||||
const mockImageId = `avatar_${Date.now()}`;
|
||||
const mockImageUrl = URL.createObjectURL(file);
|
||||
|
||||
await onUpload(mockImageId, mockImageUrl);
|
||||
|
||||
toast({
|
||||
title: 'Image uploaded',
|
||||
description: 'Your image has been uploaded successfully'
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
toast({
|
||||
title: 'Upload failed',
|
||||
description: getErrorMessage(error),
|
||||
variant: 'destructive'
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
// Reset input
|
||||
event.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileSelect}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer"
|
||||
disabled={disabled || uploading}
|
||||
/>
|
||||
{children || (
|
||||
<Button variant="outline" disabled={disabled || uploading}>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
{uploading ? 'Uploading...' : 'Upload Image'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user