mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-20 08:51:13 -05:00
Refactor: Improve validation schemas
This commit is contained in:
@@ -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} />
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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...`
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>;
|
||||||
Reference in New Issue
Block a user