feat: add complex user invitation and management features

- 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.
This commit is contained in:
Jose Selesan
2026-04-22 15:30:04 -03:00
parent 5f2a7fd494
commit 6ad4fc9ee9
37 changed files with 1935 additions and 78 deletions

View File

@@ -3,6 +3,7 @@ import { Field, FieldError, FieldLabel } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { apiClient, authClient } from '@/lib/api-client';
import { useAuth } from '@/lib/auth';
import { acceptStoredPendingInvite } from '@/lib/invitations';
import { zodResolver } from '@hookform/resolvers/zod';
import { useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
@@ -29,26 +30,46 @@ const signupSchema = z
type LoginForm = z.infer<typeof loginSchema>;
type SignupForm = z.infer<typeof signupSchema>;
export function OnboardLoginPage() {
type OnboardLoginPageProps = {
variant?: 'onboard' | 'invite';
inviteEmail?: string | null;
};
export function OnboardLoginPage({
variant = 'onboard',
inviteEmail = null,
}: OnboardLoginPageProps) {
const navigate = useNavigate();
const { signInWithPassword, signUp } = useAuth();
const [mode, setMode] = useState<'login' | 'signup'>('login');
const [mode, setMode] = useState<'login' | 'signup'>(variant === 'invite' ? 'signup' : 'login');
const [submitError, setSubmitError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const loginForm = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
mode: 'onChange',
defaultValues: { email: '', password: '' },
defaultValues: { email: inviteEmail ?? '', password: '' },
});
const signupForm = useForm<SignupForm>({
resolver: zodResolver(signupSchema),
mode: 'onChange',
defaultValues: { fullName: '', email: '', password: '', confirmPassword: '' },
defaultValues: {
fullName: '',
email: inviteEmail ?? '',
password: '',
confirmPassword: '',
},
});
async function handlePostLogin() {
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
if (variant === 'invite') {
await navigate({ to: '/' });
return;
}
const complexes = await apiClient.complexes.listMine();
if (complexes.length === 0) {
@@ -93,7 +114,12 @@ export function OnboardLoginPage() {
search: { email: values.email },
});
} else {
navigate({ to: '/onboard/create-complex' });
if (variant === 'invite') {
await acceptStoredPendingInvite(apiClient.complexes.acceptPendingInvitations);
await navigate({ to: '/' });
} else {
await handlePostLogin();
}
}
} catch {
setSubmitError('No pudimos crear la cuenta. Intenta nuevamente.');
@@ -108,7 +134,7 @@ export function OnboardLoginPage() {
try {
await authClient.signIn.social({
provider: 'google',
callbackURL: `${window.location.origin}/onboard/create-complex`,
callbackURL: `${window.location.origin}/auth-callback`,
});
} catch {
setSubmitError('No pudimos iniciar sesión con Google.');
@@ -123,8 +149,12 @@ export function OnboardLoginPage() {
</h1>
<p className="mb-4 text-sm text-muted-foreground">
{mode === 'login'
? 'Ingresá con tu cuenta para continuar.'
: 'Creá tu cuenta para comenzar.'}
? variant === 'invite'
? 'Ingresá con tu cuenta para aceptar la invitación.'
: 'Ingresá con tu cuenta para continuar.'
: variant === 'invite'
? 'Creá tu cuenta para aceptar la invitación.'
: 'Creá tu cuenta para comenzar.'}
</p>
<Button type="button" variant="outline" className="w-full" onClick={handleGoogleSignIn}>

View File

@@ -1,4 +1,5 @@
import { Button } from '@/components/ui/button';
import { getPendingInvite } from '@/lib/invitations';
import { Route } from '@/routes/onboard/verify-email';
import { Link } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
@@ -6,6 +7,7 @@ import { useEffect, useState } from 'react';
export function VerifyEmailPage() {
const search = Route.useSearch();
const email = search.email || sessionStorage.getItem('pending-signup-email') || '';
const pendingInvite = getPendingInvite();
const [resendCooldown, setResendCooldown] = useState(0);
useEffect(() => {
@@ -42,9 +44,22 @@ export function VerifyEmailPage() {
<p className="text-center text-sm text-muted-foreground">
¿Ya verificaste tu email?{' '}
<Link to="/onboard/create-complex" className="text-primary hover:underline">
Continuar
</Link>
{pendingInvite.token ? (
<Link
to="/invite"
search={{
email: pendingInvite.email ?? email,
inviteToken: pendingInvite.token,
}}
className="text-primary hover:underline"
>
Continuar con la invitación
</Link>
) : (
<Link to="/onboard/create-complex" className="text-primary hover:underline">
Continuar
</Link>
)}
</p>
</div>
</section>