- Implemented complex member and invitation schemas using Zod for validation. - Created new API endpoints for inviting, accepting, revoking, and canceling complex user invitations. - Developed handlers for managing complex users, including listing, inviting, revoking, and canceling invitations. - Added frontend components for displaying and managing complex users and invitations. - Introduced session storage management for pending invitations. - Integrated Gravatar for user avatars based on email. - Created database migration for complex invitations table.
246 lines
8.7 KiB
TypeScript
246 lines
8.7 KiB
TypeScript
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from '@/components/ui/form';
|
|
import { Input } from '@/components/ui/input';
|
|
import { UserAvatar } from '@/components/user-avatar';
|
|
import { apiClient } from '@/lib/api-client';
|
|
import { useAuth } from '@/lib/auth';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import type { UserProfileResponse } from '@repo/api-contract';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { Link } from '@tanstack/react-router';
|
|
import { useEffect } from 'react';
|
|
import { useForm } from 'react-hook-form';
|
|
import { z } from 'zod';
|
|
|
|
const updateProfileSchema = z.object({
|
|
fullName: z.string().min(1, 'El nombre es requerido'),
|
|
});
|
|
|
|
const updatePasswordSchema = z
|
|
.object({
|
|
currentPassword: z.string().min(1, 'La contraseña actual es requerida'),
|
|
newPassword: z.string().min(6, 'La nueva contraseña debe tener al menos 6 caracteres'),
|
|
confirmPassword: z.string().min(1, 'La confirmación de contraseña es requerida'),
|
|
})
|
|
.refine((data) => data.newPassword === data.confirmPassword, {
|
|
message: 'Las contraseñas no coinciden',
|
|
path: ['confirmPassword'],
|
|
});
|
|
|
|
type UpdateProfileForm = z.infer<typeof updateProfileSchema>;
|
|
type UpdatePasswordForm = z.infer<typeof updatePasswordSchema>;
|
|
|
|
export function ProfilePage() {
|
|
const { user, displayName, avatarUrl, isAuthenticated, updateProfile, updatePassword } =
|
|
useAuth();
|
|
const queryClient = useQueryClient();
|
|
const profileQuery = useQuery({
|
|
queryKey: ['user-profile'],
|
|
enabled: isAuthenticated,
|
|
queryFn: (): Promise<UserProfileResponse> => apiClient.user.getProfile(),
|
|
});
|
|
|
|
const updateProfileForm = useForm<UpdateProfileForm>({
|
|
resolver: zodResolver(updateProfileSchema),
|
|
defaultValues: {
|
|
fullName: displayName,
|
|
},
|
|
});
|
|
|
|
const updatePasswordForm = useForm<UpdatePasswordForm>({
|
|
resolver: zodResolver(updatePasswordSchema),
|
|
defaultValues: {
|
|
currentPassword: '',
|
|
newPassword: '',
|
|
confirmPassword: '',
|
|
},
|
|
});
|
|
|
|
// Sync form with displayName when session data is loaded or updated elsewhere
|
|
useEffect(() => {
|
|
if (displayName && !updateProfileForm.formState.isDirty) {
|
|
updateProfileForm.reset({ fullName: displayName });
|
|
}
|
|
}, [displayName, updateProfileForm]);
|
|
|
|
const updateProfileMutation = useMutation({
|
|
mutationFn: updateProfile,
|
|
onSuccess: (_, variables) => {
|
|
queryClient.invalidateQueries({ queryKey: ['user-profile'] });
|
|
updateProfileForm.reset({ fullName: variables.fullName });
|
|
},
|
|
});
|
|
|
|
const updatePasswordMutation = useMutation({
|
|
mutationFn: async (data: UpdatePasswordForm) => {
|
|
await updatePassword({
|
|
currentPassword: data.currentPassword,
|
|
password: data.newPassword,
|
|
});
|
|
},
|
|
onSuccess: () => {
|
|
updatePasswordForm.reset();
|
|
},
|
|
});
|
|
|
|
const onUpdateProfile = (data: UpdateProfileForm) => {
|
|
updateProfileMutation.mutate(data);
|
|
};
|
|
|
|
const onUpdatePassword = (data: UpdatePasswordForm) => {
|
|
updatePasswordMutation.mutate(data);
|
|
};
|
|
|
|
if (!isAuthenticated) {
|
|
return (
|
|
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-6">
|
|
<section className="w-full max-w-3xl rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
|
<h1 className="text-2xl font-semibold">Perfil</h1>
|
|
<p className="mt-2 text-sm text-muted-foreground">
|
|
Necesitás iniciar sesión para ver tu perfil.
|
|
</p>
|
|
<Button asChild className="mt-4">
|
|
<Link to="/login">Ir a Login</Link>
|
|
</Button>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<main className="mx-auto flex min-h-[60vh] w-full max-w-6xl items-center justify-center px-6 py-8">
|
|
<div className="w-full max-w-3xl space-y-6">
|
|
<section className="rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
|
<div className="flex items-center gap-3">
|
|
<UserAvatar
|
|
src={profileQuery.data?.avatarUrl ?? avatarUrl}
|
|
alt={displayName}
|
|
fallbackText={displayName}
|
|
size="lg"
|
|
/>
|
|
<div>
|
|
<h1 className="text-2xl font-semibold">{displayName}</h1>
|
|
<p className="text-sm text-muted-foreground">
|
|
{profileQuery.data?.email ?? user?.email}
|
|
</p>
|
|
{profileQuery.data?.role && (
|
|
<p className="text-sm text-muted-foreground">Rol: {profileQuery.data.role}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{profileQuery.isLoading && (
|
|
<p className="mt-4 text-sm text-muted-foreground">Cargando perfil...</p>
|
|
)}
|
|
|
|
{profileQuery.isError && (
|
|
<p className="mt-4 text-sm text-destructive">
|
|
No se pudo cargar el perfil desde el backend.
|
|
</p>
|
|
)}
|
|
</section>
|
|
|
|
<section className="rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
|
<h2 className="text-xl font-semibold mb-4">Editar Perfil</h2>
|
|
<Form {...updateProfileForm}>
|
|
<form onSubmit={updateProfileForm.handleSubmit(onUpdateProfile)} className="space-y-4">
|
|
<FormField
|
|
control={updateProfileForm.control}
|
|
name="fullName"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Nombre completo</FormLabel>
|
|
<FormControl>
|
|
<Input {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<Button type="submit" disabled={updateProfileMutation.isPending}>
|
|
{updateProfileMutation.isPending ? 'Guardando...' : 'Guardar cambios'}
|
|
</Button>
|
|
</form>
|
|
</Form>
|
|
{updateProfileMutation.isError && (
|
|
<p className="mt-4 text-sm text-destructive">
|
|
Error al actualizar el perfil: {updateProfileMutation.error?.message}
|
|
</p>
|
|
)}
|
|
{updateProfileMutation.isSuccess && (
|
|
<p className="mt-4 text-sm text-green-600">Perfil actualizado exitosamente.</p>
|
|
)}
|
|
</section>
|
|
|
|
<section className="rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
|
<h2 className="text-xl font-semibold mb-4">Cambiar Contraseña</h2>
|
|
<Form {...updatePasswordForm}>
|
|
<form
|
|
onSubmit={updatePasswordForm.handleSubmit(onUpdatePassword)}
|
|
className="space-y-4"
|
|
>
|
|
<FormField
|
|
control={updatePasswordForm.control}
|
|
name="currentPassword"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Contraseña actual</FormLabel>
|
|
<FormControl>
|
|
<Input type="password" {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={updatePasswordForm.control}
|
|
name="newPassword"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Nueva contraseña</FormLabel>
|
|
<FormControl>
|
|
<Input type="password" {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={updatePasswordForm.control}
|
|
name="confirmPassword"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>Confirmar nueva contraseña</FormLabel>
|
|
<FormControl>
|
|
<Input type="password" {...field} />
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<Button type="submit" disabled={updatePasswordMutation.isPending}>
|
|
{updatePasswordMutation.isPending ? 'Cambiando...' : 'Cambiar contraseña'}
|
|
</Button>
|
|
</form>
|
|
</Form>
|
|
{updatePasswordMutation.isError && (
|
|
<p className="mt-4 text-sm text-destructive">
|
|
Error al cambiar la contraseña: {updatePasswordMutation.error?.message}
|
|
</p>
|
|
)}
|
|
{updatePasswordMutation.isSuccess && (
|
|
<p className="mt-4 text-sm text-green-600">Contraseña cambiada exitosamente.</p>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|