From 09b511a1367c44cc4d5549e84387bc7363bf5447 Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 17 May 2026 02:03:18 +0330 Subject: [PATCH] bugfix: org invitation flow updated so that the invited org follows the exact steps of free trial registration. --- .../dto/accept-organization-invite.dto.ts | 8 +- .../organization/organization.service.ts | 26 +- .../accept-organization-invite/page.tsx | 266 +++++++++++++----- frontend/src/app/(public)/register/page.tsx | 85 +----- .../ui/auth/OrganizationDetailsFields.tsx | 84 ++++++ .../ui/auth/RegistrationProgressSteps.tsx | 59 ++++ frontend/src/lib/api/organization.ts | 3 + 7 files changed, 373 insertions(+), 158 deletions(-) create mode 100644 frontend/src/components/ui/auth/OrganizationDetailsFields.tsx create mode 100644 frontend/src/components/ui/auth/RegistrationProgressSteps.tsx diff --git a/backend/src/modules/organization/dto/accept-organization-invite.dto.ts b/backend/src/modules/organization/dto/accept-organization-invite.dto.ts index 4b4f692..2ef24f4 100644 --- a/backend/src/modules/organization/dto/accept-organization-invite.dto.ts +++ b/backend/src/modules/organization/dto/accept-organization-invite.dto.ts @@ -1,4 +1,4 @@ -import { IsString, MinLength } from 'class-validator'; +import { IsEmail, IsEnum, IsString, MinLength } from 'class-validator'; export class AcceptOrganizationInviteDto { @IsString() @@ -9,6 +9,12 @@ export class AcceptOrganizationInviteDto { @MinLength(1) organizationName: string; + @IsEmail() + organizationEmail: string; + + @IsEnum(['CLINIC', 'LAB']) + organizationType: 'CLINIC' | 'LAB'; + @IsString() @MinLength(1) ownerName: string; diff --git a/backend/src/modules/organization/organization.service.ts b/backend/src/modules/organization/organization.service.ts index c48bdc1..2b53c15 100644 --- a/backend/src/modules/organization/organization.service.ts +++ b/backend/src/modules/organization/organization.service.ts @@ -424,12 +424,23 @@ export class OrganizationService { async previewInvite(token: string) { const invitation = await this.findValidInvitation(token); + let organizationEmail = ''; + if (invitation.invitedOrganizationId) { + const invitedOrg = await this.prisma.organization.findUnique({ + where: { id: invitation.invitedOrganizationId }, + select: { email: true }, + }); + if (invitedOrg?.email && !invitedOrg.email.includes('@dyolink.local')) { + organizationEmail = invitedOrg.email; + } + } return { success: true, data: { ownerEmail: invitation.invitedOwnerEmail, organizationName: invitation.invitedOrganizationName, organizationType: invitation.invitedOrganizationType, + organizationEmail, inviterOrganizationName: invitation.inviterOrganization.name, expiresAt: invitation.expiresAt.toISOString(), status: invitation.acceptedAt ? 'ACCEPTED' : 'PENDING', @@ -443,6 +454,14 @@ export class OrganizationService { throw new BadRequestException('This invitation has already been accepted'); } + if (dto.organizationType !== invitation.invitedOrganizationType) { + throw new BadRequestException( + `Organization type must be ${invitation.invitedOrganizationType} for this invitation`, + ); + } + + const organizationEmail = dto.organizationEmail.trim().toLowerCase(); + const organization = await this.prisma.$transaction(async (tx) => { const passwordHash = await bcrypt.hash(dto.password, 10); const ownerEmail = invitation.invitedOwnerEmail; @@ -470,8 +489,9 @@ export class OrganizationService { where: { id: targetOrganizationId }, data: { name: dto.organizationName.trim(), - email: ownerEmail, + email: organizationEmail, owner: { connect: { id: owner.id } }, + type: { connect: { name: dto.organizationType } }, plan: { connect: { name: 'trial' } }, }, }); @@ -479,9 +499,9 @@ export class OrganizationService { const createdOrg = await tx.organization.create({ data: { name: dto.organizationName.trim(), - email: ownerEmail, + email: organizationEmail, owner: { connect: { id: owner.id } }, - type: { connect: { name: invitation.invitedOrganizationType } }, + type: { connect: { name: dto.organizationType } }, plan: { connect: { name: 'trial' } }, }, }); diff --git a/frontend/src/app/(public)/accept-organization-invite/page.tsx b/frontend/src/app/(public)/accept-organization-invite/page.tsx index 5c4af67..2e34182 100644 --- a/frontend/src/app/(public)/accept-organization-invite/page.tsx +++ b/frontend/src/app/(public)/accept-organization-invite/page.tsx @@ -3,15 +3,45 @@ import { Suspense, useEffect, useMemo, useState } from 'react'; import Link from 'next/link'; import { useRouter, useSearchParams } from 'next/navigation'; +import { useForm, type FieldErrors, type UseFormRegister, type UseFormSetValue } from 'react-hook-form'; +import type { OrganizationDetailsFormValues } from '@/components/ui/auth/OrganizationDetailsFields'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { Lock, Mail, User } from 'lucide-react'; import { Button } from '@/components/ui/common/Button'; import { Input } from '@/components/ui/common/Input'; +import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; +import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; import { organizationApi } from '@/lib/api/organization'; +const acceptOrganizationInviteSchema = z + .object({ + ownerName: z.string().min(2, 'Name must be at least 2 characters'), + password: z + .string() + .min(8, 'Password must be at least 8 characters') + .regex(/[A-Z]/, 'Password must contain at least one uppercase letter') + .regex(/[0-9]/, 'Password must contain at least one number'), + confirmPassword: z.string(), + organizationName: z.string().min(2, 'Organization name must be at least 2 characters'), + organizationEmail: z.string().email('Please enter a valid organization email'), + organizationType: z.enum(['CLINIC', 'LAB'], { + message: 'Please select organization type', + }), + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ['confirmPassword'], + }); + +type AcceptOrganizationInviteForm = z.infer; + function AcceptOrganizationInviteContent() { const params = useSearchParams(); const router = useRouter(); const token = useMemo(() => params.get('token') || '', [params]); + const [step, setStep] = useState(1); const [loading, setLoading] = useState(true); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); @@ -20,15 +50,29 @@ function AcceptOrganizationInviteContent() { ownerEmail: string; organizationName: string; organizationType: 'CLINIC' | 'LAB'; + organizationEmail?: string; inviterOrganizationName: string; expiresAt: string; status: 'PENDING' | 'ACCEPTED'; } | null>(null); - const [ownerName, setOwnerName] = useState(''); - const [organizationName, setOrganizationName] = useState(''); - const [password, setPassword] = useState(''); - const [confirmPassword, setConfirmPassword] = useState(''); + const { + register, + handleSubmit, + watch, + trigger, + setValue, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(acceptOrganizationInviteSchema), + mode: 'onChange', + defaultValues: { + organizationType: undefined, + }, + }); + + const organizationType = watch('organizationType'); useEffect(() => { if (!token) { @@ -43,102 +87,168 @@ function AcceptOrganizationInviteContent() { try { const res = await organizationApi.previewInvite(token); setInviteInfo(res.data); - setOrganizationName(res.data.organizationName || ''); + reset({ + ownerName: '', + password: '', + confirmPassword: '', + organizationName: res.data.organizationName || '', + organizationEmail: res.data.organizationEmail || '', + organizationType: res.data.organizationType, + }); if (res.data.status === 'ACCEPTED') { setSuccess('This invitation is already accepted. You can log in now.'); } - } catch (e: any) { - setError(e?.message || 'Could not load invitation'); + } catch (e: unknown) { + const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; + setError(message || 'Could not load invitation'); } finally { setLoading(false); } })(); - }, [token]); + }, [token, reset]); - async function onAccept() { + const handleNext = async () => { + const isValid = await trigger(['ownerName', 'password', 'confirmPassword']); + if (isValid) { + setStep(2); + setError(''); + } + }; + + const onSubmit = async (data: AcceptOrganizationInviteForm) => { if (!token) return; setError(''); setSuccess(''); - if (!ownerName.trim()) return setError('Owner name is required'); - if (!organizationName.trim()) return setError('Organization name is required'); - if (password.length < 8) return setError('Password must be at least 8 characters'); - if (password !== confirmPassword) return setError('Passwords do not match'); - setSubmitting(true); try { await organizationApi.acceptInvite({ token, - ownerName: ownerName.trim(), - organizationName: organizationName.trim(), - password, + ownerName: data.ownerName.trim(), + password: data.password, + organizationName: data.organizationName.trim(), + organizationEmail: data.organizationEmail.trim(), + organizationType: data.organizationType, }); - setSuccess('Invitation Accepted. Redirecting to login...'); + setSuccess('Invitation accepted. Redirecting to login...'); setTimeout(() => router.replace('/login'), 1000); - } catch (e: any) { - setError(e?.message || 'Could not accept invitation'); + } catch (e: unknown) { + const message = e && typeof e === 'object' && 'message' in e ? String(e.message) : ''; + setError(message || 'Could not accept invitation'); } finally { setSubmitting(false); } - } + }; return ( -
-
-

Accept organization invitation

- {loading ? ( -

Loading invitation...

- ) : ( - <> - {inviteInfo && ( -
-

- Invited by: {inviteInfo.inviterOrganizationName} -

-

- Owner email: {inviteInfo.ownerEmail} -

-
- )} - {error && ( -
- {error} -
- )} - {success && ( -
- {success} -
- )} - {inviteInfo?.status !== 'ACCEPTED' && ( -
- setOwnerName(e.target.value)} /> - setOrganizationName(e.target.value)} - /> - setPassword(e.target.value)} - /> - setConfirmPassword(e.target.value)} - /> - -
- )} -

- Already have access? Go to login -

- - )} +
+
+ + DyoLink + +

+ Accept organization invitation +

+

+ Already have an account?{' '} + + Sign in + +

+
+ +
+
+ {loading ? ( +

Loading invitation...

+ ) : ( + <> + {inviteInfo && ( +
+

+ Invited by:{' '} + {inviteInfo.inviterOrganizationName} +

+
+ )} + + {inviteInfo?.status !== 'ACCEPTED' && ( + + )} + + {error && ( +
+

{error}

+
+ )} + {success && ( +
+ {success} +
+ )} + + {inviteInfo?.status !== 'ACCEPTED' && ( +
+ {step === 1 && ( + <> + } + /> + } + /> + } + /> + } + /> + + + )} + + {step === 2 && ( + <> + } + errors={errors as FieldErrors} + organizationType={organizationType} + setValue={setValue as unknown as UseFormSetValue} + /> +
+ + +
+ + )} + + )} + + )} +
); diff --git a/frontend/src/app/(public)/register/page.tsx b/frontend/src/app/(public)/register/page.tsx index 1d32b65..deda726 100644 --- a/frontend/src/app/(public)/register/page.tsx +++ b/frontend/src/app/(public)/register/page.tsx @@ -5,8 +5,10 @@ import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; import Link from 'next/link'; -import { Building2, Mail, Lock, User, ChevronRight } from 'lucide-react'; +import { Mail, Lock, User } from 'lucide-react'; import { useAuth } from '@/lib/hooks/useAuth'; +import { OrganizationDetailsFields } from '@/components/ui/auth/OrganizationDetailsFields'; +import { RegistrationProgressSteps } from '@/components/ui/auth/RegistrationProgressSteps'; import { Button } from '@/components/ui/common/Button'; import { Input } from '@/components/ui/common/Input'; const registerSchema = z.object({ @@ -89,31 +91,7 @@ export default function RegisterPage() {
- {/* Progress Steps */} -
-
-
-
= 1 ? 'bg-primary text-primary-contrast' : 'bg-background-secondary text-text-secondary border border-border'}`}> - 1 -
-
= 1 ? 'text-primary' : 'text-text-muted' - }`}> - Account -
-
- -
-
= 2 ? 'bg-primary text-primary-contrast' : 'bg-background-secondary text-text-secondary border border-border'}`}> - - 2 -
-
= 2 ? 'text-primary' : 'text-text-muted' - }`}> - Organization -
-
-
-
+ {/* Trial Info Banner */}

Your trial @@ -177,57 +155,12 @@ export default function RegisterPage() { )} {step === 2 && ( <> - } + - } - /> -
- - -
- - -
- {errors.organizationType && ( -

{errors.organizationType.message}

- )} -
{error && (

{error}

diff --git a/frontend/src/components/ui/auth/OrganizationDetailsFields.tsx b/frontend/src/components/ui/auth/OrganizationDetailsFields.tsx new file mode 100644 index 0000000..957646a --- /dev/null +++ b/frontend/src/components/ui/auth/OrganizationDetailsFields.tsx @@ -0,0 +1,84 @@ +'use client'; + +import { Building2, Mail } from 'lucide-react'; +import type { FieldErrors, UseFormRegister, UseFormSetValue } from 'react-hook-form'; +import { Input } from '@/components/ui/common/Input'; + +export type OrganizationDetailsFormValues = { + organizationName: string; + organizationEmail: string; + organizationType: 'CLINIC' | 'LAB'; +}; + +type OrganizationDetailsFieldsProps = { + register: UseFormRegister; + errors: FieldErrors; + organizationType: 'CLINIC' | 'LAB' | undefined; + setValue: UseFormSetValue; +}; + +export function OrganizationDetailsFields({ + register, + errors, + organizationType, + setValue, +}: OrganizationDetailsFieldsProps) { + return ( + <> + } + /> + } + /> +
+ + +
+ + +
+ {errors.organizationType && ( +

{errors.organizationType.message}

+ )} +
+ + ); +} diff --git a/frontend/src/components/ui/auth/RegistrationProgressSteps.tsx b/frontend/src/components/ui/auth/RegistrationProgressSteps.tsx new file mode 100644 index 0000000..16d91de --- /dev/null +++ b/frontend/src/components/ui/auth/RegistrationProgressSteps.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { ChevronRight } from 'lucide-react'; + +type RegistrationProgressStepsProps = { + step: number; + firstLabel?: string; + secondLabel?: string; +}; + +export function RegistrationProgressSteps({ + step, + firstLabel = 'Account', + secondLabel = 'Organization', +}: RegistrationProgressStepsProps) { + return ( +
+
+
+
= 1 + ? 'bg-primary text-primary-contrast' + : 'bg-background-secondary text-text-secondary border border-border' + }`} + > + 1 +
+
= 1 ? 'text-primary' : 'text-text-muted' + }`} + > + {firstLabel} +
+
+ +
+
= 2 + ? 'bg-primary text-primary-contrast' + : 'bg-background-secondary text-text-secondary border border-border' + }`} + > + 2 +
+
= 2 ? 'text-primary' : 'text-text-muted' + }`} + > + {secondLabel} +
+
+
+
+ ); +} diff --git a/frontend/src/lib/api/organization.ts b/frontend/src/lib/api/organization.ts index bd65b4b..ba2af00 100644 --- a/frontend/src/lib/api/organization.ts +++ b/frontend/src/lib/api/organization.ts @@ -92,6 +92,7 @@ export const organizationApi = { ownerEmail: string; organizationName: string; organizationType: 'CLINIC' | 'LAB'; + organizationEmail?: string; inviterOrganizationName: string; expiresAt: string; status: 'PENDING' | 'ACCEPTED'; @@ -106,6 +107,8 @@ export const organizationApi = { acceptInvite: async (body: { token: string; organizationName: string; + organizationEmail: string; + organizationType: 'CLINIC' | 'LAB'; ownerName: string; password: string; }): Promise<{ success: boolean; message: string; data: { organizationId: string } }> => {