diff --git a/src/components/auth/MFAChallenge.tsx b/src/components/auth/MFAChallenge.tsx index b36bb2e3..09237a3f 100644 --- a/src/components/auth/MFAChallenge.tsx +++ b/src/components/auth/MFAChallenge.tsx @@ -89,6 +89,7 @@ export function MFAChallenge({ factorId, onSuccess, onCancel }: MFAChallengeProp value={code} onChange={setCode} onComplete={handleVerify} + onPaste={(e) => e.preventDefault()} > diff --git a/src/components/auth/TOTPSetup.tsx b/src/components/auth/TOTPSetup.tsx index 070345f9..b6a9488a 100644 --- a/src/components/auth/TOTPSetup.tsx +++ b/src/components/auth/TOTPSetup.tsx @@ -220,6 +220,7 @@ export function TOTPSetup() { id="verificationCode" value={verificationCode} onChange={(e) => setVerificationCode(e.target.value)} + onPaste={(e) => e.preventDefault()} placeholder="000000" maxLength={6} className="text-center text-lg tracking-widest font-mono" diff --git a/src/components/settings/AccountProfileTab.tsx b/src/components/settings/AccountProfileTab.tsx index e74351ba..ef01e984 100644 --- a/src/components/settings/AccountProfileTab.tsx +++ b/src/components/settings/AccountProfileTab.tsx @@ -1,7 +1,6 @@ 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 { Textarea } from '@/components/ui/textarea'; @@ -24,11 +23,13 @@ import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { toast as sonnerToast } from 'sonner'; import { AccountDeletionDialog } from './AccountDeletionDialog'; import { DeletionStatusBanner } from './DeletionStatusBanner'; +import { usernameSchema, displayNameSchema, bioSchema } from '@/lib/validation'; +import { z } from 'zod'; const profileSchema = z.object({ - username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_-]+$/), - display_name: z.string().max(50).optional(), - bio: z.string().max(500).optional(), + username: usernameSchema, + display_name: displayNameSchema, + bio: bioSchema, preferred_pronouns: z.string().max(20).optional(), show_pronouns: z.boolean(), preferred_language: z.string() diff --git a/src/components/settings/EmailChangeDialog.tsx b/src/components/settings/EmailChangeDialog.tsx index 88c4097d..32b6daca 100644 --- a/src/components/settings/EmailChangeDialog.tsx +++ b/src/components/settings/EmailChangeDialog.tsx @@ -157,9 +157,17 @@ export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }: setStep('success'); } catch (error: any) { console.error('Email change error:', error); - toast.error('Failed to change email', { - description: error.message || 'Please try again.', - }); + + // Handle rate limiting specifically + if (error.message?.includes('rate limit') || error.status === 429) { + toast.error('Too Many Attempts', { + description: 'Please wait a few minutes before trying again.', + }); + } else { + toast.error('Failed to change email', { + description: error.message || 'Please try again.', + }); + } } finally { setLoading(false); } diff --git a/src/components/settings/LocationTab.tsx b/src/components/settings/LocationTab.tsx index d8df84f3..17bf6330 100644 --- a/src/components/settings/LocationTab.tsx +++ b/src/components/settings/LocationTab.tsx @@ -15,11 +15,13 @@ import { useProfile } from '@/hooks/useProfile'; import { useUnitPreferences } from '@/hooks/useUnitPreferences'; import { supabase } from '@/integrations/supabase/client'; import { MapPin, Calendar, Globe, Accessibility, Ruler } from 'lucide-react'; +import { personalLocationSchema } from '@/lib/validation'; + const locationSchema = z.object({ preferred_pronouns: z.string().max(20).optional(), timezone: z.string(), preferred_language: z.string(), - personal_location: z.string().max(100).optional(), + personal_location: personalLocationSchema, home_park_id: z.string().optional() }); type LocationFormData = z.infer; diff --git a/src/components/settings/PasswordUpdateDialog.tsx b/src/components/settings/PasswordUpdateDialog.tsx index 328c8ff0..9406b4b7 100644 --- a/src/components/settings/PasswordUpdateDialog.tsx +++ b/src/components/settings/PasswordUpdateDialog.tsx @@ -1,7 +1,6 @@ import { useState, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; import { Dialog, DialogContent, @@ -19,15 +18,9 @@ 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'; -const passwordSchema = z.object({ - currentPassword: z.string().min(1, 'Current password is required'), - newPassword: z.string().min(8, 'Password must be at least 8 characters'), - confirmPassword: z.string() -}).refine(data => data.newPassword === data.confirmPassword, { - message: "Passwords don't match", - path: ["confirmPassword"] -}); +import { z } from 'zod'; type PasswordFormData = z.infer; @@ -124,11 +117,20 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password await updatePasswordWithNonce(data.newPassword, generatedNonce); } } catch (error: any) { - toast({ - title: 'Authentication Error', - description: error.message || 'Incorrect password. Please try again.', - variant: 'destructive' - }); + // Handle rate limiting specifically + if (error.message?.includes('rate limit') || error.status === 429) { + toast({ + title: 'Too Many Attempts', + description: 'Please wait a few minutes before trying again.', + variant: 'destructive' + }); + } else { + toast({ + title: 'Authentication Error', + description: error.message || 'Incorrect password. Please try again.', + variant: 'destructive' + }); + } } finally { setLoading(false); } @@ -280,6 +282,9 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password placeholder="Enter your new password" disabled={loading} /> +

+ Must be 8+ characters with uppercase, lowercase, number, and special character +

{form.formState.errors.newPassword && (

{form.formState.errors.newPassword.message} @@ -348,6 +353,7 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password value={totpCode} onChange={setTotpCode} disabled={loading} + onPaste={(e) => e.preventDefault()} > diff --git a/src/components/settings/SecurityTab.tsx b/src/components/settings/SecurityTab.tsx index e33abd54..734e78e2 100644 --- a/src/components/settings/SecurityTab.tsx +++ b/src/components/settings/SecurityTab.tsx @@ -60,19 +60,36 @@ export function SecurityTab() { }; const handleSocialLogin = async (provider: OAuthProvider) => { - const result = await connectIdentity(provider, '/settings?tab=security'); - - if (!result.success) { + try { + const result = await connectIdentity(provider, '/settings?tab=security'); + + if (!result.success) { + // Handle rate limiting + if (result.error?.includes('rate limit')) { + toast({ + title: 'Too Many Attempts', + description: 'Please wait a few minutes before trying again.', + variant: 'destructive' + }); + } else { + toast({ + title: 'Connection Failed', + description: result.error, + variant: 'destructive' + }); + } + } else { + toast({ + title: 'Redirecting...', + description: `Connecting your ${provider} account...` + }); + } + } catch (error: any) { toast({ - title: 'Connection Failed', - description: result.error, + title: 'Connection Error', + description: error.message || 'Failed to connect account', variant: 'destructive' }); - } else { - toast({ - title: 'Redirecting...', - description: `Connecting your ${provider} account...` - }); } }; diff --git a/src/lib/validation.ts b/src/lib/validation.ts index a5ff02d5..32e1df79 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -32,8 +32,14 @@ export const usernameSchema = z .string() .min(3, 'Username must be at least 3 characters') .max(30, 'Username must be less than 30 characters') - .regex(/^[a-zA-Z0-9_-]+$/, 'Username can only contain letters, numbers, underscores, and hyphens') - .regex(/^[a-zA-Z0-9]/, 'Username must start with a letter or number') + .regex( + /^[a-zA-Z0-9]([a-zA-Z0-9_-]*[a-zA-Z0-9])?$/, + 'Username must start and end with letters/numbers, and can only contain letters, numbers, hyphens, and underscores' + ) + .refine( + (val) => !/[-_]{2,}/.test(val), + 'Username cannot contain consecutive hyphens or underscores' + ) .transform(val => val.toLowerCase()) .refine(val => !FORBIDDEN_USERNAMES.has(val), 'This username is not allowed'); @@ -50,10 +56,46 @@ export const displayNameSchema = z }, 'Display name contains inappropriate content') .optional(); +// Password validation schema with complexity requirements +export const passwordSchema = z.object({ + currentPassword: z.string().min(1, 'Current password is required'), + newPassword: z.string() + .min(8, 'Password must be at least 8 characters') + .max(128, 'Password must be less than 128 characters') + .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') + .regex(/[a-z]/, 'Password must contain at least one lowercase letter') + .regex(/[0-9]/, 'Password must contain at least one number') + .regex(/[^A-Za-z0-9]/, 'Password must contain at least one special character'), + confirmPassword: z.string() +}).refine(data => data.newPassword === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"] +}); + +// Bio field validation with sanitization +export const bioSchema = z.string() + .max(500, 'Bio must be less than 500 characters') + .transform(val => val?.trim()) + .refine( + val => !val || !/[<>]/.test(val), + 'Bio cannot contain HTML tags' + ) + .optional(); + +// Personal location field validation with sanitization +export const personalLocationSchema = z.string() + .max(100, 'Location must be less than 100 characters') + .transform(val => val?.trim()) + .refine( + val => !val || !/[<>{}]/.test(val), + 'Location cannot contain special characters' + ) + .optional(); + export const profileEditSchema = z.object({ username: usernameSchema, display_name: displayNameSchema, - bio: z.string().max(500, 'Bio must be less than 500 characters').optional(), + bio: bioSchema, }); export type ProfileEditForm = z.infer; \ No newline at end of file