Реализация приглашения пользователей по email/ссылке

Наша компания занимается разработкой, поддержкой и обслуживанием сайтов любой сложности. От простых одностраничных сайтов до масштабных кластерных систем построенных на микро сервисах. Опыт разработчиков подтвержден сертификатами от вендоров.

Разработка и обслуживание любых видов сайтов:

Информационные сайты или веб-приложения
Сайты визитки, landing page, корпоративные сайты, онлайн каталоги, квиз, промо-сайты, блоги, новостные ресурсы, информационные порталы, форумы, агрегаторы
Сайты или веб-приложения электронной коммерции
Интернет-магазины, B2B-порталы, маркетплейсы, онлайн-обменники, кэшбэк-сайты, биржи, дропшиппинг-платформы, парсеры товаров
Веб-приложения для управления бизнес-процессами
CRM-системы, ERP-системы, корпоративные порталы, системы управления производством, парсеры информации
Сайты или веб-приложения электронных услуг
Доски объявлений, онлайн-школы, онлайн-кинотеатры, конструкторы сайтов, порталы предоставления электронных услуг, видеохостинги, тематические порталы

Это лишь некоторые из технических типов сайтов, с которыми мы работаем, и каждый из них может иметь свои специфические особенности и функциональность, а также быть адаптированным под конкретные потребности и цели клиента

Предлагаемые услуги
Показано 1 из 1 услугВсе 2065 услуг
Реализация приглашения пользователей по email/ссылке
Простая
от 1 рабочего дня до 3 рабочих дней
Часто задаваемые вопросы

Наши компетенции:

Этапы разработки

Последние работы

  • image_website-b2b-advance_0.png
    Разработка сайта компании B2B ADVANCE
    1262
  • image_web-applications_feedme_466_0.webp
    Разработка веб-приложения для компании FEEDME
    1171
  • image_websites_belfingroup_462_0.webp
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    874
  • image_ecommerce_furnoro_435_0.webp
    Разработка интернет магазина для компании FURNORO
    1094
  • image_crm_enviok_479_0.webp
    Разработка веб-приложения для компании Enviok
    831
  • image_bitrix-bitrix-24-1c_fixper_448_0.png
    Разработка веб-сайта для компании ФИКСПЕР
    851

Система приглашений пользователей по email

Invitation flow: пользователь приглашает коллегу → тот получает письмо со ссылкой → регистрируется и автоматически добавляется в нужную организацию. Без приглашения — не попасть (для закрытых B2B продуктов).

Схема данных

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: отправка приглашения

// 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');

  // Проверяем права на приглашение
  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));

  // Проверяем: уже участник?
  const existingMember = await db.user.findFirst({
    where: {
      email,
      organizations: { some: { organizationId } }
    }
  });
  if (existingMember) throw new Error('User already a member');

  // Проверяем: уже есть активное приглашение?
  const existingInvite = await db.invitation.findFirst({
    where: {
      email,
      organizationId,
      status: 'PENDING',
      expiresAt: { gt: new Date() },
    }
  });
  if (existingInvite) throw new Error('Invitation already sent');

  // Создаём приглашение
  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 дней
    },
    include: {
      organization: true,
      invitedBy: true,
    }
  });

  // Отправляем письмо
  await sendEmail({
    to: email,
    subject: `Вас приглашают в ${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('ru-RU'),
      role: ROLE_LABELS[role],
    },
  });

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

Страница принятия приглашения

// 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}
    />
  );
}
// Принятие приглашения
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 (!session) {
    redirect(`/register?invite=${token}&email=${encodeURIComponent(invitation.email)}`);
  }

  // Проверяем email соответствие
  if (session.user.email !== invitation.email) {
    throw new Error('This invitation was sent to a different email');
  }

  await db.$transaction([
    // Добавляем в организацию
    db.organizationMember.create({
      data: {
        organizationId: invitation.organizationId,
        userId: session.user.id,
        role: invitation.role,
      }
    }),
    // Обновляем статус
    db.invitation.update({
      where: { id: invitation.id },
      data: {
        status: 'ACCEPTED',
        acceptedAt: new Date(),
      }
    }),
  ]);

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

Email шаблон (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} приглашает вас в {organizationName}</Preview>
      <Body style={{ fontFamily: 'sans-serif', backgroundColor: '#f5f5f5' }}>
        <Container style={{ maxWidth: '560px', margin: '40px auto' }}>
          <Text>Привет!</Text>
          <Text>
            <strong>{inviterName}</strong> приглашает вас присоединиться к{' '}
            <strong>{organizationName}</strong> в роли <strong>{role}</strong>.
          </Text>
          <Section style={{ textAlign: 'center', margin: '32px 0' }}>
            <Button
              href={inviteUrl}
              style={{
                backgroundColor: '#6366f1',
                color: '#fff',
                padding: '12px 32px',
                borderRadius: '8px',
              }}
            >
              Принять приглашение
            </Button>
          </Section>
          <Text style={{ color: '#666', fontSize: '14px' }}>
            Ссылка действительна до {expiresAt}. Если вы не ожидали этого приглашения — просто проигнорируйте письмо.
          </Text>
        </Container>
      </Body>
    </Html>
  );
}

Настройка invitation flow с email через React Email/Resend и обработкой edge cases — 2–3 рабочих дня.