veza/apps/web/src/schemas/validation.ts

417 lines
12 KiB
TypeScript

/**
* Validation schemas using Zod
* Provides strict client-side validation for all forms and user inputs
*/
import { z } from 'zod';
// Schémas de base pour les types communs
export const emailSchema = z
.string()
.min(1, "L'email est requis")
.email("Format d'email invalide")
.max(255, "L'email est trop long")
.toLowerCase();
// T0197: Updated to match backend validation (min 8 characters)
export const passwordSchema = z
.string()
.min(8, 'Le mot de passe doit contenir au moins 8 caractères')
.max(128, 'Le mot de passe est trop long')
.regex(/[A-Z]/, 'Le mot de passe doit contenir au moins une majuscule')
.regex(/[a-z]/, 'Le mot de passe doit contenir au moins une minuscule')
.regex(/[0-9]/, 'Le mot de passe doit contenir au moins un chiffre')
.regex(
/[!@#$%^&*(),.?":{}|<>]/,
'Le mot de passe doit contenir au moins un caractère spécial',
)
.refine(
(password) => !/(.)\1{3,}/.test(password),
'Le mot de passe ne doit pas contenir de répétition de caractères',
)
.refine(
(password) => !/123456|password|qwerty/i.test(password),
'Le mot de passe ne doit pas contenir de patterns communs',
);
export const usernameSchema = z
.string()
.min(3, "Le nom d'utilisateur doit contenir au moins 3 caractères")
.max(30, "Le nom d'utilisateur est trop long")
.regex(
/^[a-zA-Z0-9_-]+$/,
"Le nom d'utilisateur ne peut contenir que des lettres, chiffres, tirets et underscores",
)
.refine(
(username) => !/^(admin|root|user|test|demo)$/i.test(username),
"Ce nom d'utilisateur est réservé",
);
export const displayNameSchema = z
.string()
.min(1, "Le nom d'affichage est requis")
.max(50, "Le nom d'affichage est trop long")
.regex(
/^[a-zA-ZÀ-ÿ0-9\s\-'.]+$/,
"Le nom d'affichage contient des caractères non autorisés",
);
// Schémas pour l'authentification
export const loginSchema = z.object({
email: emailSchema,
password: z.string().min(1, 'Le mot de passe est requis'),
remember_me: z.boolean().optional(),
captcha: z.string().optional(),
});
export const registerSchema = z
.object({
email: emailSchema,
password: passwordSchema,
confirmPassword: z.string(),
username: usernameSchema,
displayName: displayNameSchema,
acceptTerms: z
.boolean()
.refine(
(val) => val === true,
"Vous devez accepter les conditions d'utilisation",
),
acceptPrivacy: z
.boolean()
.refine(
(val) => val === true,
'Vous devez accepter la politique de confidentialité',
),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Les mots de passe ne correspondent pas',
path: ['confirmPassword'],
});
export const forgotPasswordSchema = z.object({
email: emailSchema,
});
export const resetPasswordSchema = z
.object({
token: z.string().min(1, 'Le token de réinitialisation est requis'),
password: passwordSchema,
confirmPassword: z.string(),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Les mots de passe ne correspondent pas',
path: ['confirmPassword'],
});
export const changePasswordSchema = z
.object({
currentPassword: z.string().min(1, 'Le mot de passe actuel est requis'),
newPassword: passwordSchema,
confirmNewPassword: z.string(),
})
.refine((data) => data.newPassword === data.confirmNewPassword, {
message: 'Les nouveaux mots de passe ne correspondent pas',
path: ['confirmNewPassword'],
})
.refine((data) => data.currentPassword !== data.newPassword, {
message: "Le nouveau mot de passe doit être différent de l'actuel",
path: ['newPassword'],
});
// Schémas pour les fichiers
export const fileUploadSchema = z
.object({
file: z.instanceof(File, { message: 'Un fichier est requis' }),
title: z
.string()
.min(1, 'Le titre est requis')
.max(255, 'Le titre est trop long'),
artist: z
.string()
.min(1, "L'artiste est requis")
.max(255, "L'artiste est trop long"),
description: z
.string()
.max(1000, 'La description est trop longue')
.optional(),
tags: z
.array(z.string().max(50))
.max(10, 'Maximum 10 tags autorisés')
.optional(),
isPublic: z.boolean().optional(),
})
.refine(
(data) => {
const maxSize = 100 * 1024 * 1024; // 100MB
return data.file.size <= maxSize;
},
{
message: 'Le fichier est trop volumineux (maximum 100MB)',
path: ['file'],
},
)
.refine(
(data) => {
const allowedTypes = [
'audio/mpeg',
'audio/mp3',
'audio/wav',
'audio/flac',
'audio/aac',
'audio/ogg',
'audio/m4a',
];
return allowedTypes.includes(data.file.type);
},
{
message: 'Type de fichier non supporté',
path: ['file'],
},
);
export const imageUploadSchema = z
.object({
file: z.instanceof(File, { message: 'Une image est requise' }),
alt: z.string().max(255, 'Le texte alternatif est trop long').optional(),
caption: z.string().max(500, 'La légende est trop longue').optional(),
})
.refine(
(data) => {
const maxSize = 10 * 1024 * 1024; // 10MB
return data.file.size <= maxSize;
},
{
message: "L'image est trop volumineuse (maximum 10MB)",
path: ['file'],
},
)
.refine(
(data) => {
const allowedTypes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
];
return allowedTypes.includes(data.file.type);
},
{
message: "Type d'image non supporté",
path: ['file'],
},
);
// Schémas pour les messages de chat
export const chatMessageSchema = z.object({
content: z
.string()
.min(1, 'Le message ne peut pas être vide')
.max(2000, 'Le message est trop long (maximum 2000 caractères)')
.refine(
(content) => !/<script|javascript:|vbscript:|on\w+=/i.test(content),
'Le message contient du contenu non autorisé',
),
conversationId: z.string().uuid('ID de conversation invalide'),
replyToId: z.string().uuid('ID de réponse invalide').optional(),
attachments: z
.array(z.string().uuid())
.max(5, 'Maximum 5 pièces jointes')
.optional(),
});
// Schémas pour les conversations
export const createConversationSchema = z.object({
name: z.string().min(1, 'Le nom est requis').max(100, 'Le nom est trop long'),
description: z.string().max(500, 'La description est trop longue').optional(),
isPrivate: z.boolean().optional(),
participants: z
.array(z.string().uuid())
.min(1, 'Au moins un participant est requis')
.max(50, 'Maximum 50 participants'),
});
export const updateConversationSchema = z.object({
name: z
.string()
.min(1, 'Le nom est requis')
.max(100, 'Le nom est trop long')
.optional(),
description: z.string().max(500, 'La description est trop longue').optional(),
isPrivate: z.boolean().optional(),
});
// Schémas pour le profil utilisateur
export const updateProfileSchema = z.object({
displayName: displayNameSchema.optional(),
bio: z.string().max(500, 'La bio est trop longue').optional(),
avatar: z.string().url("URL d'avatar invalide").optional(),
website: z.string().url('URL de site web invalide').optional(),
location: z.string().max(100, 'La localisation est trop longue').optional(),
birthDate: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Format de date invalide')
.optional(),
isPublic: z.boolean().optional(),
});
// Schémas pour les paramètres
export const updateSettingsSchema = z.object({
email: emailSchema.optional(),
language: z
.enum(['en', 'fr', 'es', 'de'], {
errorMap: () => ({ message: 'Langue non supportée' }),
})
.optional(),
timezone: z.string().max(50, 'Timezone invalide').optional(),
notifications: z
.object({
email: z.boolean().optional(),
push: z.boolean().optional(),
chat: z.boolean().optional(),
mentions: z.boolean().optional(),
})
.optional(),
privacy: z
.object({
showOnlineStatus: z.boolean().optional(),
showLastSeen: z.boolean().optional(),
allowDirectMessages: z.boolean().optional(),
showInSearch: z.boolean().optional(),
})
.optional(),
});
// Schémas pour la recherche
export const searchSchema = z.object({
query: z
.string()
.min(1, 'La requête de recherche est requise')
.max(100, 'La requête est trop longue'),
type: z
.enum(['all', 'users', 'tracks', 'conversations'], {
errorMap: () => ({ message: 'Type de recherche invalide' }),
})
.optional(),
limit: z.number().min(1).max(100).optional(),
offset: z.number().min(0).optional(),
});
// Schémas pour la pagination
export const paginationSchema = z.object({
page: z
.number()
.min(1, 'Le numéro de page doit être supérieur à 0')
.optional(),
limit: z
.number()
.min(1)
.max(100, 'La limite doit être entre 1 et 100')
.optional(),
cursor: z.string().optional(),
sortBy: z.string().max(50).optional(),
sortOrder: z.enum(['asc', 'desc']).optional(),
});
// Schémas pour les filtres
export const trackFiltersSchema = z.object({
genre: z.string().max(50).optional(),
artist: z.string().max(100).optional(),
year: z.number().min(1900).max(new Date().getFullYear()).optional(),
duration: z
.object({
min: z.number().min(0).optional(),
max: z.number().min(0).optional(),
})
.optional(),
isPublic: z.boolean().optional(),
});
// Schémas pour les réponses API
export const apiResponseSchema = <T extends z.ZodType>(dataSchema: T) =>
z.object({
success: z.boolean(),
data: dataSchema.optional(),
error: z
.object({
code: z.number(),
message: z.string(),
details: z.any().optional(),
})
.optional(),
meta: z
.object({
timestamp: z.string(),
requestId: z.string().optional(),
pagination: paginationSchema.optional(),
})
.optional(),
});
// Schémas pour les erreurs
export const errorSchema = z.object({
code: z.number(),
message: z.string(),
details: z.any().optional(),
field: z.string().optional(),
timestamp: z.string(),
});
// Types TypeScript générés à partir des schémas
export type LoginFormData = z.infer<typeof loginSchema>;
export type RegisterFormData = z.infer<typeof registerSchema>;
export type ForgotPasswordFormData = z.infer<typeof forgotPasswordSchema>;
export type ResetPasswordFormData = z.infer<typeof resetPasswordSchema>;
export type ChangePasswordFormData = z.infer<typeof changePasswordSchema>;
export type FileUploadFormData = z.infer<typeof fileUploadSchema>;
export type ImageUploadFormData = z.infer<typeof imageUploadSchema>;
export type ChatMessageFormData = z.infer<typeof chatMessageSchema>;
export type CreateConversationFormData = z.infer<
typeof createConversationSchema
>;
export type UpdateConversationFormData = z.infer<
typeof updateConversationSchema
>;
export type UpdateProfileFormData = z.infer<typeof updateProfileSchema>;
export type UpdateSettingsFormData = z.infer<typeof updateSettingsSchema>;
export type SearchFormData = z.infer<typeof searchSchema>;
export type PaginationFormData = z.infer<typeof paginationSchema>;
export type TrackFiltersFormData = z.infer<typeof trackFiltersSchema>;
export type ErrorFormData = z.infer<typeof errorSchema>;
// Utilitaires de validation
export function validateForm<T>(
schema: z.ZodSchema<T>,
data: unknown,
): {
success: boolean;
data?: T;
errors?: Record<string, string[]>;
} {
try {
const result = schema.parse(data);
return { success: true, data: result };
} catch (error) {
if (error instanceof z.ZodError) {
const errors: Record<string, string[]> = {};
error.errors.forEach((err) => {
const path = err.path.join('.');
if (!errors[path]) {
errors[path] = [];
}
errors[path].push(err.message);
});
return { success: false, errors };
}
return {
success: false,
errors: { general: ['Erreur de validation inattendue'] },
};
}
}
// Hook React pour la validation
export function useValidation<T>(schema: z.ZodSchema<T>) {
return (data: unknown) => validateForm(schema, data);
}