417 lines
12 KiB
TypeScript
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);
|
|
}
|