User Invitation via Email/Link

Our company is engaged in the development, support and maintenance of sites of any complexity. From simple one-page sites to large-scale cluster systems built on micro services. Experience of developers is confirmed by certificates from vendors.
Development and maintenance of all types of websites:
Informational websites or web applications
Business card websites, landing pages, corporate websites, online catalogs, quizzes, promo websites, blogs, news resources, informational portals, forums, aggregators
E-commerce websites or web applications
Online stores, B2B portals, marketplaces, online exchanges, cashback websites, exchanges, dropshipping platforms, product parsers
Business process management web applications
CRM systems, ERP systems, corporate portals, production management systems, information parsers
Electronic service websites or web applications
Classified ads platforms, online schools, online cinemas, website builders, portals for electronic services, video hosting platforms, thematic portals

These are just some of the technical types of websites we work with, and each of them can have its own specific features and functionality, as well as be customized to meet the specific needs and goals of the client.

Our competencies:
Development stages
Latest works
  • image_website-b2b-advance_0.png
    B2B ADVANCE company website development
    1212
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1161
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    852
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1041
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    822
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Website development for FIXPER company
    815

User Invitation System via Email

Invitation flow: user invites a colleague who receives an email with a link, registers, and is automatically added to the needed organization. Without an invitation, access is denied (for closed B2B products).

Data Schema

model Invitation {
  id             String           @id @default(cuid())
  email          String
  organizationId String
  role           OrganizationRole @default(MEMBER)
  token          String           @unique @default(cuid())
  invitedById    String
  status         InvitationStatus @default(PENDING)
  expiresAt      DateTime
  createdAt      DateTime         @default(now())
  acceptedAt     DateTime?

  organization Organization @relation(fields: [organizationId], references: [id])
  invitedBy    User         @relation(fields: [invitedById], references: [id])
}

enum InvitationStatus {
  PENDING
  ACCEPTED
  EXPIRED
  REVOKED
}

enum OrganizationRole {
  OWNER
  ADMIN
  MEMBER
  VIEWER
}

Server Action: Sending Invitation

// app/organization/invite/actions.ts
'use server';

import { auth } from '@/auth';
import { db } from '@/lib/db';
import { sendEmail } from '@/lib/email';
import { z } from 'zod';

const inviteSchema = z.object({
  email: z.string().email(),
  role: z.enum(['ADMIN', 'MEMBER', 'VIEWER']).default('MEMBER'),
});

export async function inviteUser(
  organizationId: string,
  formData: FormData
) {
  const session = await auth();
  if (!session) throw new Error('Unauthorized');

  // Check invitation permissions
  const membership = await db.organizationMember.findFirst({
    where: {
      organizationId,
      userId: session.user.id,
      role: { in: ['OWNER', 'ADMIN'] },
    }
  });
  if (!membership) throw new Error('Insufficient permissions');

  const { email, role } = inviteSchema.parse(Object.fromEntries(formData));

  // Check: already a member?
  const existingMember = await db.user.findFirst({
    where: {
      email,
      organizations: { some: { organizationId } }
    }
  });
  if (existingMember) throw new Error('User already a member');

  // Check: is there an active invitation already?
  const existingInvite = await db.invitation.findFirst({
    where: {
      email,
      organizationId,
      status: 'PENDING',
      expiresAt: { gt: new Date() },
    }
  });
  if (existingInvite) throw new Error('Invitation already sent');

  // Create invitation
  const invitation = await db.invitation.create({
    data: {
      email,
      organizationId,
      role,
      invitedById: session.user.id,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
    },
    include: {
      organization: true,
      invitedBy: true,
    }
  });

  // Send email
  await sendEmail({
    to: email,
    subject: `You're invited to ${invitation.organization.name}`,
    template: 'invitation',
    variables: {
      organizationName: invitation.organization.name,
      inviterName: invitation.invitedBy.name!,
      inviteUrl: `${process.env.APP_URL}/invite/${invitation.token}`,
      expiresAt: invitation.expiresAt.toLocaleDateString('en-US'),
      role: ROLE_LABELS[role],
    },
  });

  return { success: true, invitationId: invitation.id };
}

Invitation Acceptance Page

// app/invite/[token]/page.tsx
import { db } from '@/lib/db';
import { auth } from '@/auth';
import { redirect } from 'next/navigation';

export default async function InvitationPage({
  params,
}: {
  params: { token: string };
}) {
  const invitation = await db.invitation.findUnique({
    where: { token: params.token },
    include: { organization: true, invitedBy: true },
  });

  if (!invitation || invitation.status !== 'PENDING') {
    return <InvitationExpired />;
  }

  if (invitation.expiresAt < new Date()) {
    await db.invitation.update({
      where: { id: invitation.id },
      data: { status: 'EXPIRED' }
    });
    return <InvitationExpired />;
  }

  return (
    <InvitationAcceptPage
      invitation={invitation}
      token={params.token}
    />
  );
}
// Accept invitation
export async function acceptInvitation(token: string) {
  'use server';

  const session = await auth();

  const invitation = await db.invitation.findUnique({
    where: { token, status: 'PENDING' },
  });

  if (!invitation || invitation.expiresAt < new Date()) {
    throw new Error('Invalid or expired invitation');
  }

  // If user is not logged in — redirect to registration with token saved
  if (!session) {
    redirect(`/register?invite=${token}&email=${encodeURIComponent(invitation.email)}`);
  }

  // Check email match
  if (session.user.email !== invitation.email) {
    throw new Error('This invitation was sent to a different email');
  }

  await db.$transaction([
    // Add to organization
    db.organizationMember.create({
      data: {
        organizationId: invitation.organizationId,
        userId: session.user.id,
        role: invitation.role,
      }
    }),
    // Update status
    db.invitation.update({
      where: { id: invitation.id },
      data: {
        status: 'ACCEPTED',
        acceptedAt: new Date(),
      }
    }),
  ]);

  redirect(`/org/${invitation.organizationId}/dashboard`);
}

Email Template (React Email)

// emails/invitation.tsx
import {
  Body, Button, Container, Head, Html,
  Preview, Section, Text,
} from '@react-email/components';

export function InvitationEmail({
  organizationName,
  inviterName,
  inviteUrl,
  expiresAt,
  role,
}: InvitationEmailProps) {
  return (
    <Html>
      <Head />
      <Preview>{inviterName} invites you to {organizationName}</Preview>
      <Body style={{ fontFamily: 'sans-serif', backgroundColor: '#f5f5f5' }}>
        <Container style={{ maxWidth: '560px', margin: '40px auto' }}>
          <Text>Hi!</Text>
          <Text>
            <strong>{inviterName}</strong> invites you to join{' '}
            <strong>{organizationName}</strong> as <strong>{role}</strong>.
          </Text>
          <Section style={{ textAlign: 'center', margin: '32px 0' }}>
            <Button
              href={inviteUrl}
              style={{
                backgroundColor: '#6366f1',
                color: '#fff',
                padding: '12px 32px',
                borderRadius: '8px',
              }}
            >
              Accept Invitation
            </Button>
          </Section>
          <Text style={{ color: '#666', fontSize: '14px' }}>
            Link valid until {expiresAt}. If you didn't expect this invitation, just ignore this email.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

Setting up invitation flow with email via React Email/Resend and handling edge cases — 2–3 working days.