Refactor code structure and remove redundant changes

This commit is contained in:
pacnpal
2025-11-09 16:31:34 -05:00
parent 2884bc23ce
commit eb68cf40c6
1080 changed files with 27361 additions and 56687 deletions

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}