Refactor: Improve validation schemas

This commit is contained in:
gpt-engineer-app[bot]
2025-10-14 17:52:50 +00:00
parent a255442616
commit 7a6273111d
8 changed files with 113 additions and 35 deletions

View File

@@ -89,6 +89,7 @@ export function MFAChallenge({ factorId, onSuccess, onCancel }: MFAChallengeProp
value={code} value={code}
onChange={setCode} onChange={setCode}
onComplete={handleVerify} onComplete={handleVerify}
onPaste={(e) => e.preventDefault()}
> >
<InputOTPGroup> <InputOTPGroup>
<InputOTPSlot index={0} /> <InputOTPSlot index={0} />

View File

@@ -220,6 +220,7 @@ export function TOTPSetup() {
id="verificationCode" id="verificationCode"
value={verificationCode} value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)} onChange={(e) => setVerificationCode(e.target.value)}
onPaste={(e) => e.preventDefault()}
placeholder="000000" placeholder="000000"
maxLength={6} maxLength={6}
className="text-center text-lg tracking-widest font-mono" className="text-center text-lg tracking-widest font-mono"

View File

@@ -1,7 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; 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 { toast as sonnerToast } from 'sonner';
import { AccountDeletionDialog } from './AccountDeletionDialog'; import { AccountDeletionDialog } from './AccountDeletionDialog';
import { DeletionStatusBanner } from './DeletionStatusBanner'; import { DeletionStatusBanner } from './DeletionStatusBanner';
import { usernameSchema, displayNameSchema, bioSchema } from '@/lib/validation';
import { z } from 'zod';
const profileSchema = z.object({ const profileSchema = z.object({
username: z.string().min(3).max(30).regex(/^[a-zA-Z0-9_-]+$/), username: usernameSchema,
display_name: z.string().max(50).optional(), display_name: displayNameSchema,
bio: z.string().max(500).optional(), bio: bioSchema,
preferred_pronouns: z.string().max(20).optional(), preferred_pronouns: z.string().max(20).optional(),
show_pronouns: z.boolean(), show_pronouns: z.boolean(),
preferred_language: z.string() preferred_language: z.string()

View File

@@ -157,9 +157,17 @@ export function EmailChangeDialog({ open, onOpenChange, currentEmail, userId }:
setStep('success'); setStep('success');
} catch (error: any) { } catch (error: any) {
console.error('Email change error:', error); 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 { } finally {
setLoading(false); setLoading(false);
} }

View File

@@ -15,11 +15,13 @@ import { useProfile } from '@/hooks/useProfile';
import { useUnitPreferences } from '@/hooks/useUnitPreferences'; import { useUnitPreferences } from '@/hooks/useUnitPreferences';
import { supabase } from '@/integrations/supabase/client'; import { supabase } from '@/integrations/supabase/client';
import { MapPin, Calendar, Globe, Accessibility, Ruler } from 'lucide-react'; import { MapPin, Calendar, Globe, Accessibility, Ruler } from 'lucide-react';
import { personalLocationSchema } from '@/lib/validation';
const locationSchema = z.object({ const locationSchema = z.object({
preferred_pronouns: z.string().max(20).optional(), preferred_pronouns: z.string().max(20).optional(),
timezone: z.string(), timezone: z.string(),
preferred_language: z.string(), preferred_language: z.string(),
personal_location: z.string().max(100).optional(), personal_location: personalLocationSchema,
home_park_id: z.string().optional() home_park_id: z.string().optional()
}); });
type LocationFormData = z.infer<typeof locationSchema>; type LocationFormData = z.infer<typeof locationSchema>;

View File

@@ -1,7 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -19,15 +18,9 @@ import { Loader2, Shield, CheckCircle2 } from 'lucide-react';
import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp'; import { InputOTP, InputOTPGroup, InputOTPSlot } from '@/components/ui/input-otp';
import { TurnstileCaptcha } from '@/components/auth/TurnstileCaptcha'; import { TurnstileCaptcha } from '@/components/auth/TurnstileCaptcha';
import { useTheme } from '@/components/theme/ThemeProvider'; import { useTheme } from '@/components/theme/ThemeProvider';
import { passwordSchema } from '@/lib/validation';
const passwordSchema = z.object({ import { z } from 'zod';
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"]
});
type PasswordFormData = z.infer<typeof passwordSchema>; type PasswordFormData = z.infer<typeof passwordSchema>;
@@ -124,11 +117,20 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password
await updatePasswordWithNonce(data.newPassword, generatedNonce); await updatePasswordWithNonce(data.newPassword, generatedNonce);
} }
} catch (error: any) { } catch (error: any) {
toast({ // Handle rate limiting specifically
title: 'Authentication Error', if (error.message?.includes('rate limit') || error.status === 429) {
description: error.message || 'Incorrect password. Please try again.', toast({
variant: 'destructive' 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 { } finally {
setLoading(false); setLoading(false);
} }
@@ -280,6 +282,9 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password
placeholder="Enter your new password" placeholder="Enter your new password"
disabled={loading} 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 && ( {form.formState.errors.newPassword && (
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
{form.formState.errors.newPassword.message} {form.formState.errors.newPassword.message}
@@ -348,6 +353,7 @@ export function PasswordUpdateDialog({ open, onOpenChange, onSuccess }: Password
value={totpCode} value={totpCode}
onChange={setTotpCode} onChange={setTotpCode}
disabled={loading} disabled={loading}
onPaste={(e) => e.preventDefault()}
> >
<InputOTPGroup> <InputOTPGroup>
<InputOTPSlot index={0} /> <InputOTPSlot index={0} />

View File

@@ -60,19 +60,36 @@ export function SecurityTab() {
}; };
const handleSocialLogin = async (provider: OAuthProvider) => { const handleSocialLogin = async (provider: OAuthProvider) => {
const result = await connectIdentity(provider, '/settings?tab=security'); try {
const result = await connectIdentity(provider, '/settings?tab=security');
if (!result.success) {
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({ toast({
title: 'Connection Failed', title: 'Connection Error',
description: result.error, description: error.message || 'Failed to connect account',
variant: 'destructive' variant: 'destructive'
}); });
} else {
toast({
title: 'Redirecting...',
description: `Connecting your ${provider} account...`
});
} }
}; };

View File

@@ -32,8 +32,14 @@ export const usernameSchema = z
.string() .string()
.min(3, 'Username must be at least 3 characters') .min(3, 'Username must be at least 3 characters')
.max(30, 'Username must be less than 30 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(
.regex(/^[a-zA-Z0-9]/, 'Username must start with a letter or number') /^[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()) .transform(val => val.toLowerCase())
.refine(val => !FORBIDDEN_USERNAMES.has(val), 'This username is not allowed'); .refine(val => !FORBIDDEN_USERNAMES.has(val), 'This username is not allowed');
@@ -50,10 +56,46 @@ export const displayNameSchema = z
}, 'Display name contains inappropriate content') }, 'Display name contains inappropriate content')
.optional(); .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({ export const profileEditSchema = z.object({
username: usernameSchema, username: usernameSchema,
display_name: displayNameSchema, display_name: displayNameSchema,
bio: z.string().max(500, 'Bio must be less than 500 characters').optional(), bio: bioSchema,
}); });
export type ProfileEditForm = z.infer<typeof profileEditSchema>; export type ProfileEditForm = z.infer<typeof profileEditSchema>;