Я провел немало поисков и изо всех сил пытался найти отличный поток аутентификации «Пригласить пользователя» через поставщика электронной почты для NextAuth.js, поэтому я решил собрать его вместе. Надеемся, что это пошаговое руководство поможет другим пользователям получить полезную отправную точку для работы в своих собственных приложениях!

Примечание. Перед началом работы я настоятельно рекомендую обратиться к этому руководству из Auth.js для получения дополнительного контекста: https://authjs.dev/getting-started/email-tutorial.

Технический стек

  • NextJS 13 (каталог страниц)
  • TailwindCSS
  • Машинопись
  • NextAuth v4
  • Облако Атласа MongoDB
  • Nodemailer
  • SendGrid

Настройка проекта

Создать приложение NextJS

Первое, что вы собираетесь настроить, — это ваше приложение NextJS. Откройте терминал и измените каталог на то место, где вы храните свои приложения. В терминале выполните одну из следующих команд (выберите ту, которая связана с вашим предпочтительным менеджером пакетов):

npx create-next-app@latest
 
yarn create next-app
 
pnpm create next-app

Чтобы следовать этому руководству, введите следующие подсказки соответственно:

What is your project named?  nextauth-invite
Would you like to use TypeScript?  Yes
Would you like to use ESLint?  Yes
Would you like to use Tailwind CSS?  Yes
Would you like to use `src/` directory?  No
Would you like to use App Router? (recommended)  No
Would you like to customize the default import alias?  No

Начните с очистки шаблонного содержимого проекта:

  • Удалите все из styles/globals.css, кроме 3 включений @tailwind.
  • Удалите все внутри тегов ‹main› из файла pages/index.tsx.
  • Удалите файл API pages/api/hello.ts.

Измените каталог в своем приложении:

cd nextauth-invite

Установить зависимости

Запустите команду установки, связанную с вашим менеджером пакетов:

npm i next-auth nodemailer @auth/mongodb-adapter mongodb @types/nodemailer

yarn add next-auth nodemailer @auth/mongodb-adapter mongodb @types/nodemailer

pnpm add next-auth nodemailer @auth/mongodb-adapter mongodb @types/nodemailer

Настройте учетную запись MongoDB Atlas

…
Я могу добавить это позже, но пока вы сами по себе. Уже существует множество хороших руководств о том, как создать учетную запись MongoDB Atlas и сгенерировать необходимые учетные данные для этого проекта.

Как правило, вы можете следовать руководству, предоставленному MongoDB: MongoDB Atlas Tutorial

  • Используйте «Общий кластер» для простого и бесплатного (в зависимости от использования) уровня.
  • Обязательно выберите «Node.js» при выборе метода подключения «Подключить приложение».
  • Вы можете остановиться после «Создания строки подключения к базе данных».

Настройка учетной записи SendGrid

…
Возможно, я добавлю это позже, но пока вы сами по себе. SendGrid имеет очень удобный процесс настройки; просто убедитесь, что вы выбрали опцию «SMTP Relay» при переходе через Email API › Руководство по интеграции.

Настроить файл .env

Создайте файл .env.local в корневой папке вашего проекта. Убедитесь, что ваш .gitignore исключает локальные файлы env, чтобы предотвратить раскрытие конфиденциальных данных. Откройте файл .env.local и добавьте следующие значения:

// .env.local

# NextAuth Variables
NEXTAUTH_URL=http://localhost:3000/
NEXTAUTH_SECRET=<randomString>

# MongoDB Variables
MONGODB_DB=<databasename>
MONGODB_URI=mongodb+srv://<user>:<password>@<cluster>.<id>.mongodb.net/$MONGODB_DB

# SendGrid Variables
SMTP_USER=apikey
SMTP_PASSWORD=<apiKey>
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
EMAIL_FROM=<emailAddress>

Примечание. Замените все ‹переменные› выше соответствующими значениями; при использовании SendGrid значение SMTP_USER на самом деле представляет собой просто строку «apikey». Вы также должны обязательно обновить NEXTAUTH_URL до URL-адреса вашей рабочей среды при развертывании в рабочей среде.

Теперь, когда у вас есть первоначальный проект, вы можете запустить свой сервер разработки! Запустите соответствующую команду в своем терминале:

npm run dev

yarn dev

Настройка маршрутов API NextAuth

Затем вы собираетесь инициализировать маршруты и конфигурации провайдера NextAuth API, создав следующий универсальный маршрут: pages/api/auth/[…nextauth].ts

Исходная структура

Откройте файл […nextauth].ts и добавьте следующий код, чтобы начать:

// pages/api/auth/[...nextauth].ts

import NextAuth from "next-auth"
import type { NextAuthOptions } from "next-auth"

export const authOptions: NextAuthOptions = {
  providers: []
}

export default NextAuth(authOptions)

Примечание. Настройка параметров конфигурации в отдельной переменной authOptions позволяет экспортировать ее для использования при доступе к сеансу на сервере.

Настроить провайдера электронной почты

Затем вы собираетесь импортировать и добавить поставщика электронной почты в параметры конфигурации, считывая из переменных среды параметры подключения к серверу:

// pages/api/auth/[...nextauth].ts

import NextAuth from "next-auth"
import type { NextAuthOptions } from "next-auth"
import Email from "next-auth/providers/email"

export const authOptions: NextAuthOptions = {
  providers: [
    Email({
      server: {
        host: process.env.SMTP_HOST,
        port: Number(process.env.SMTP_PORT),
        auth: {
          user: process.env.SMTP_USER,
          pass: process.env.SMTP_PASSWORD,
        },
      },
      from: process.env.EMAIL_FROM,
    }),
  ],
}

export default NextAuth(authOptions)

Настройка клиента и адаптера базы данных

Чтобы ваш провайдер электронной почты работал, вам нужно настроить адаптер базы данных, который будет хранить данные учетной записи, сеанса, пользователя и проверки. Начните с создания клиента MongoDB:

// lib/mongodb/client.ts

// This approach is taken from https://github.com/vercel/next.js/tree/canary/examples/with-mongodb
import { MongoClient } from "mongodb";

if (!process.env.MONGODB_URI) {
  throw new Error('Invalid/Missing environment variable: "MONGODB_URI"');
}

const uri = process.env.MONGODB_URI;
const options = {};

let client;
let clientPromise: Promise<MongoClient>;

// Added for typing of globalThis index signature
// Remove this variable and replace all "globalWithMongo"
// references with "global" if not using typescript
let globalWithMongo = global as typeof globalThis & {
  _mongoClientPromise: Promise<MongoClient>
}

if (process.env.NODE_ENV === "development") {
  // In development mode, use a global variable so that the value
  // is preserved across module reloads caused by HMR (Hot Module Replacement).
  if (!globalWithMongo._mongoClientPromise) {
    client = new MongoClient(uri, options);
    globalWithMongo._mongoClientPromise = client.connect();
  }
  clientPromise = globalWithMongo._mongoClientPromise;
} else {
  // In production mode, it's best to not use a global variable.
  client = new MongoClient(uri, options);
  clientPromise = client.connect();
}

// Export a module-scoped MongoClient promise. By doing this in a
// separate module, the client can be shared across functions.
export default clientPromise;

Теперь, когда у вас настроен клиент MongoDB, добавьте адаптер в параметры конфигурации NextAuth:

// pages/api/auth/[...nextauth].ts

import NextAuth from "next-auth"
import type { NextAuthOptions } from "next-auth"
import Email from "next-auth/providers/email"
import { MongoDBAdapter } from "@auth/mongodb-adapter"
import clientPromise from "@/lib/mongodb/client"

export const authOptions: NextAuthOptions = {
  providers: [
    Email({
      server: {
        host: process.env.SMTP_HOST,
        port: Number(process.env.SMTP_PORT),
        auth: {
          user: process.env.SMTP_USER,
          pass: process.env.SMTP_PASSWORD,
        },
      },
      from: process.env.EMAIL_FROM,
    }),
  ],
  // @ts-expect-error
  adapter: MongoDBAdapter(clientPromise),
  session: {
    strategy: 'jwt',
  },
}

export default NextAuth(authOptions)

Я добавил к адаптеру флаг @ts-expect-error, поскольку у MongoDBAdapter есть ошибка типа, связанная с типом createUser из основного интерфейса адаптера Auth.js. Я не копался, чтобы правильно устранить ошибку Typescript.

Примечание. По умолчанию при добавлении адаптера к параметрам конфигурации NextAuth для стратегии сеанса по умолчанию используется «база данных», а не «jwt». Мы собираемся использовать промежуточное ПО NextJS с оболочкой withAuth() от NextAuth для ограничения доступа к странице. withAuth() в настоящее время поддерживает только JWT, поэтому вы увидите, что в этой конфигурации для стратегии сеанса явно установлено значение «jwt».

Функциональность на стороне клиента

Добавить оболочку контекста сеанса

Теперь пришло время добавить ваш SessionProvider для контекста сеанса приложения. Откройте файл _app.tsx и обновите его соответствующим образом:

// _app.tsx

import "@/styles/globals.css"
import type { AppProps } from "next/app" 
import { SessionProvider } from "next-auth/react" 
 
export default function App({ 
  Component, 
  pageProps: { session, ...pageProps }
}: AppProps) { 
  return ( 
    <SessionProvider session={session}> 
      <Component {...pageProps} /> 
    </SessionProvider> 
  )
}

На этом этапе у вас должен быть настроен полнофункциональный вход в систему без пароля! Вы можете подтвердить это, перейдя по адресу http://localhost:3000/api/auth/signin и попытавшись войти в систему с адресом электронной почты.

Создайте пользовательскую страницу входа

Хотя встроенные страницы NextAuth функциональны, ваше приложение будет выглядеть намного более профессионально с отдельной страницей входа, так что давайте настроим ее дальше. Для простоты вы будете использовать домашнюю страницу проекта (pages/index.tsx) в качестве страницы входа в ваше приложение. Откройте файл pages/index.tsx и обновите код следующим образом:

// pages/index.tsx

import { signIn } from "next-auth/react"
import { useState } from 'react'

export default function Home() {

  // State Variables
  const [email, setEmail] = useState('')
  const [loading, setLoading] = useState(false)
  const [formType, setFormType] = useState('signIn')

  /**
   * * Handle Sign In
   * @dev handler for more control over the sign in user experience
   * @param e formEvent
   */
  const handleSignIn = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    setLoading(true)
    await signIn('email', { email, callbackUrl: '/admin/dashboard' })
    setLoading(false)
  }

  return (
    <main className='bg-slate-300 relative flex min-h-screen flex-col items-center justify-center p-24'>
      <div className="bg-white py-8 px-20 shadow-black shadow-sm rounded-sm">
        {/* Logo */}
        <div className="mb-8 text-center">
            YOUR_LOGO
        </div>

        {/* Form */}
        <div className="w-full max-w-sm">
          <h1 className="font-bold text-3xl mb-6 text-center">{formType === 'signIn' ? 'Sign In' : 'Create Your Account'}</h1>
          <form onSubmit={handleSignIn}>

            <div className="relative">
              <label
                htmlFor="email"
                className="absolute -top-2 left-2 text-xs bg-white px-1 font-medium"
              >
                Email Address
              </label>
              <input
                type="email"
                id="email"
                name="email"
                autoComplete='email'
                placeholder="[email protected]"
                required
                value={email}
                onChange={(e) => setEmail(e.target.value)}
                className="block w-full border border-neutral-400 placeholder:text-neutral-400 py-2.5 px-5 rounded-sm"
              />
            </div>

            <button
              type="submit"
              disabled={loading}
              className="block w-full py-2.5 px-5 mt-4 bg-cyan-600 text-white text-center rounded-sm"
            >
              {loading ? 'Please Wait...' : 'Continue'}
            </button>
          </form>

          <p className="text-center text-sm mt-4">
            {formType === 'signIn' ? 'Don\'t have an account?' : 'Already have an account?'}
            <button
              className="inline-block text-cyan-600 ml-2"
              onClick={() => setFormType(formType === 'signIn' ? 'signUp' : 'signIn')}
            >{formType === 'signIn' ? 'Sign up' : 'Sign in'}</button>
          </p>
        </div>

        {/* Bottom Links */}
        <div className="text-center text-sm mt-12">
          <a
            href="#"
            className="transition duration-300 text-cyan-600 mx-2"
          >Terms of Use</a>
          {' '}|{' '}
          <a
            href="#"
            className="transition duration-300 text-cyan-600 mx-2"
          >Privacy Policy</a>
        </div>
      </div>
    </main>
  )
}

Примечание. Это простой шаблон, который я собрал для вашей страницы входа, и вы можете заменить его чем-то своим. Наиболее важной частью здесь является то, что есть поле ввода для ввода адреса электронной почты и кнопка, которая вызывает signIn('email', { email, callbackUrl: '/admin/dashboard' }) для запуска поток аутентификации электронной почты без пароля.

Следующее, что вам нужно сделать, это обновить конфигурацию параметров NextAuth, чтобы отразить пользовательскую страницу входа. Откройте файл […nextauth].ts и добавьте соответствующую опцию «pages»:

// pages/api/auth/[...nextauth].ts
import NextAuth from "next-auth"
import type { NextAuthOptions } from "next-auth"
import Email from 'next-auth/providers/email'
import { MongoDBAdapter } from "@auth/mongodb-adapter"
import clientPromise from "@/lib/mongodb/client"

export const authOptions: NextAuthOptions = {
  providers: [
    Email({
      server: {
        host: process.env.SMTP_HOST,
        port: Number(process.env.SMTP_PORT),
        auth: {
          user: process.env.SMTP_USER,
          pass: process.env.SMTP_PASSWORD,
        },
      },
      from: process.env.EMAIL_FROM,
    }),
  ],
  // @ts-expect-error
  adapter: MongoDBAdapter(clientPromise),
  session: {
    strategy: 'jwt',
  },
  pages: {
    signIn: '/',
  },
}

export default NextAuth(authOptions)

Создать страницу панели инструментов

Теперь вы можете создать страницу «панели инструментов», чтобы направлять пользователей после входа в систему. Создайте путь к папке /admin/dashboard внутри вашей папки /pages и добавьте файл index.tsx в новый каталог /dashboard. Откройте этот файл и добавьте следующий код, который будет включать ссылку на страницу «пригласить», которую вы собираетесь создать, а также ссылку «выйти».

// pages/admin/dashboard/index.tsx

import { signOut } from "next-auth/react"
import Link from "next/link"

export default function DashboardPage() {
    return (
        <main className="flex min-h-screen flex-col items-center justify-center p-24">
            <section className="text-center">
                <h1 className="text-5xl mb-4">Welcome to the Dashboard</h1>
                <h2 className="text-2xl">You have reached a secure area!</h2>
                <div className="mt-8">
                    <Link
                        className="inline-block py-2.5 px-5 bg-cyan-600 text-white rounded-sm"
                        href="/admin/invite"
                    >Invite Users</Link>
                </div>
                <div className="mt-8">
                    <button
                        onClick={() => signOut({ callbackUrl: '/' })}
                        className="text-cyan-600 rounded-md"
                    >Sign Out</button>
                </div>
            </section>
        </main>
    )
}

Добавить промежуточное ПО для ограничений страницы

Теперь, когда у вас есть страница «вход» и «панель инструментов», пришло время настроить начальные ограничения страницы для вашего приложения. Создайте файл middleware.ts в корневой папке вашего проекта и добавьте следующий код:

// middleware.ts

import { NextRequestWithAuth, withAuth } from "next-auth/middleware"

export default withAuth(
    // middleware is triggered AFTER callbacks, allowing for additional authentication logic after the callbacks have been completed
    function middleware(req: NextRequestWithAuth) {
        console.log('middleware/token', req.nextauth.token)
    },
    {
        // callbacks are triggered first
        callbacks: {
            // the authorized callback restricts all matched paths, redirecting unauthorized responses to our signIn page
            authorized: (params) => {
                let { token } = params
                return !!token // returns true (indicating "authorized" status) if token is not null (indicating a logged in status)
            }
        },
        pages: {
            signIn: '/',
        },
    }
)

// This middleware will only restrict paths defined in the matcher array below
export const config = { matcher: ["/admin/:path*"] }

При использовании «сопоставителя» это промежуточное ПО будет работать на всех страницах внутри маршрута «/admin» страницы вашего приложения, сначала проверяя авторизованный статус, проверяя существующий токен, а затем записывая значения токена JWT в журнал. консоль. Любые пользователи, которые не вошли в систему, не будут иметь действительного токена JWT, и при попытке доступа к любой странице в маршруте «/admin» они будут перенаправлены на страницу «вход». Попробуйте! Обязательно выйдите из системы, если вы завершили процесс авторизации ранее.

Примечание. Существует несколько способов применить ограничение страницы к вашему приложению, и вы можете использовать любой из них, который вам больше подходит.

Добавить поток аутентификации приглашенного пользователя

Теперь пришло время расширить функциональность потока авторизации, чтобы позволить аутентифицированным пользователям приглашать пользователей в ваше приложение!

Создайте поставщика приглашений по электронной почте

Чтобы отделить поток «приглашение по электронной почте» от стандартного потока входа «по электронной почте», вы можете просто скопировать существующего поставщика «электронной почты» и добавить некоторые незначительные изменения. Поскольку вы будете дублировать настройки конфигурации, начните с создания новой папки с именем «smtpRelay» внутри вашей папки /lib. Добавьте файл config.ts в папку smtpRelay и скопируйте соответствующие параметры конфигурации сервера:

// lib/smtpRelay/config.ts

const smtpRelayConfig = {
    host: process.env.SMTP_HOST,
    port: Number(process.env.SMTP_PORT),
    auth: {
        user: process.env.SMTP_USER,
        pass: process.env.SMTP_PASSWORD,
    },
}

export default smtpRelayConfig

Теперь откройте файл […nextauth].ts и обновите код. Вы увидите, что мы использовали функцию sendVerificationRequest для переопределения функций уведомлений по электронной почте по умолчанию для наших приглашающих электронных писем — процесс, взятый из официальной документации NextAuth Email. Я также добавил расширение интерфейса, чтобы включить поддержку Typescript для изменения свойств id и name интерфейса EmailUserConfig поставщика электронной почты.

// pages/api/auth/[...nextauth].ts

import NextAuth from "next-auth"
import type { NextAuthOptions, Theme } from "next-auth"
import Email, { EmailUserConfig } from 'next-auth/providers/email'
import { MongoDBAdapter } from "@auth/mongodb-adapter"
import clientPromise from "@/lib/mongodb/client"
import smtpRelayConfig from "@/lib/smtpRelay/config"
import { createTransport } from "nodemailer"

interface ExtendedEmailUserConfig extends EmailUserConfig {
  id?: string
  name?: string
}

/**
 * Email HTML body
 * Insert invisible space into domains from being turned into a hyperlink by email
 * clients like Outlook and Apple mail, as this is confusing because it seems
 * like they are supposed to click on it to sign in.
 *
 * @note We don't add the email address to avoid needing to escape it, if you do, remember to sanitize it!
 */
function htmlInvite(params: { url: string; host: string; theme: Theme }) {
  const { url, host, theme } = params

  const escapedHost = host.replace(/\./g, "&#8203;.")
  const brandColor = theme.brandColor || "#346df1"
  const buttonText = theme.buttonText || "#fff"

  const color = {
    background: "#f9f9f9",
    text: "#444",
    mainBackground: "#fff",
    buttonBackground: brandColor,
    buttonBorder: brandColor,
    buttonText,
  }

  return `
<body style="background: ${color.background};">
  <table width="100%" border="0" cellspacing="20" cellpadding="0"
    style="background: ${color.mainBackground}; max-width: 600px; margin: auto; border-radius: 10px;">
    <tr>
      <td align="center"
        style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
        You have been invited to <strong>${escapedHost}</strong>
      </td>
    </tr>
    <tr>
      <td align="center" style="padding: 20px 0;">
        <table border="0" cellspacing="0" cellpadding="0">
          <tr>
            <td align="center" style="border-radius: 5px;" bgcolor="${color.buttonBackground}"><a href="${url}"
                target="_blank"
                style="font-size: 18px; font-family: Helvetica, Arial, sans-serif; color: ${color.buttonText}; text-decoration: none; border-radius: 5px; padding: 10px 20px; border: 1px solid ${color.buttonBorder}; display: inline-block; font-weight: bold;">Accept Invite</a></td>
          </tr>
        </table>
      </td>
    </tr>
    <tr>
      <td
        style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
        <strong>Note:</strong> If you were not expecting this invitation, you can ignore this email. did not request this email you can safely ignore it.
      </td>
    </tr>
    <tr>
      <td
        style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
        <strong>Button not working?</strong> Copy and paste this link into your browser: ${url}
      </td>
    </tr>
  </table>
</body>
`
}

/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
function textInvite({ url, host }: { url: string; host: string }) {
  return `Accept invite to to ${host}\n${url}\n\n`
}

const emailOptions: ExtendedEmailUserConfig = {
  id: 'email',
  name: 'Email',
  server: smtpRelayConfig,
  from: process.env.EMAIL_FROM,
}

const inviteEmailOptions: ExtendedEmailUserConfig = {
  id: 'emailInvite',
  name: 'Email Invite',
  server: smtpRelayConfig,
  from: process.env.EMAIL_FROM,
  sendVerificationRequest: async (params) => {
    const { identifier, url, provider, theme } = params
    const { host } = new URL(url)
    const transport = createTransport(provider.server)
    const result = await transport.sendMail({
      to: identifier,
      from: provider.from,
      subject: `You have been invited to ${host}`,
      text: textInvite({ url, host }),
      html: htmlInvite({ url, host, theme }),
    })
    const failed = result.rejected.concat(result.pending).filter(Boolean)
    if (failed.length) {
      throw new Error(`Email Invite (${failed.join(", ")}) could not be sent`)
    }
  }
}

export const authOptions: NextAuthOptions = {
  providers: [
    Email(emailOptions),
    Email(inviteEmailOptions),
  ],
  // @ts-expect-error
  adapter: MongoDBAdapter(clientPromise),
  session: {
    strategy: 'jwt',
  },
  pages: {
    signIn: '/',
  },
}

export default NextAuth(authOptions)

Создать страницу приглашения

Идите вперед и создайте маршрут для приглашения/index.tsx в своем приложении и вставьте измененный код «входа», используемый на главной странице:

// pages/admin/invite/index.tsx

import { signIn } from "next-auth/react"
import Link from "next/link"
import { useState } from 'react'

export default function Invite() {

    // State Variables
    const [email, setEmail] = useState('')
    const [loading, setLoading] = useState(false)

    /**
     * * Handle Send Invite
     * @dev handler for more control over the send invite user experience
     * @param e formEvent
     */
    const handleSendInvite = async (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault()
        setLoading(true)
        await signIn('emailInvite', { email, callbackUrl: '/admin/view-invites' })
        setLoading(false)
    }

    return (
        <main className='relative flex min-h-screen flex-col items-center justify-center p-24 bg-slate-300'>
            <div className="bg-white p-20 rounded-md shadow-black shadow-sm">
                {/* Logo */}
                <div className="mb-8 text-center">
                    YOUR_LOGO
                </div>

                {/* Form */}
                <div className="w-full max-w-sm">
                    <h1 className="font-bold text-3xl mb-6 text-center">Invite User</h1>
                    <form onSubmit={handleSendInvite}>
                        <div className="relative">
                            <label
                                htmlFor="email"
                                className="bg-white absolute -top-2 left-2 text-xs px-1 font-medium"
                            >
                                Email Address
                            </label>
                            <input
                                type="email"
                                id="email"
                                name="email"
                                autoComplete='email'
                                placeholder="[email protected]"
                                required
                                value={email}
                                onChange={(e) => setEmail(e.target.value)}
                                className="block w-full border border-neutral-400 placeholder:text-neutral-400 py-2.5 px-5 rounded-sm"
                            />
                        </div>
                        <button
                            type="submit"
                            disabled={loading}
                            className="block w-full py-2.5 px-5 mt-4 bg-cyan-600 text-white text-center rounded-sm"
                        >
                            {loading ? 'Please Wait...' : 'Send Invite'}
                        </button>
                        <p className="text-center text-sm mt-4">
                            Return to the{' '}
                            <Link
                                href="/admin/dashboard"
                                className="inline-block text-cyan-600"
                            >Dashboard</Link>
                        </p>
                    </form>
                </div>
            </div>
        </main>
    )
}

Запретить перенаправление с потока аутентификации приглашения по электронной почте

Ваша функция «Приглашение по электронной почте» должна работать хорошо, однако в настоящее время она по-прежнему следует стандартному потоку аутентификации входа и перенаправляет вашего пользователя на страницу запроса проверки NextAuth. Хотя вы можете создать пользовательскую страницу «подтвердить запрос» таким же образом, как и пользовательскую страницу «входа», вы можете немного упростить ситуацию, предотвратив стандартное перенаправление и обработав ответ непосредственно на странице приглашения. Откройте инвайт/index.tsx и соответствующим образом обновите функцию handleSendInvite:

// pages/admin/invite/index.tsx

import { signIn } from "next-auth/react"
import Link from "next/link"
import { useState } from 'react'

export default function Invite() {

    // State Variables
    const [email, setEmail] = useState('')
    const [loading, setLoading] = useState(false)

    /**
     * * Handle Send Invite
     * @dev handler for more control over the send invite user experience
     * @param e formEvent
     */
    const handleSendInvite = async (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault()
        setLoading(true)

        // Sned the email invite request
        const resp = await signIn(
            'emailInvite',
            {
                email,
                callbackUrl: '/auth/view-invites',
                redirect: false
            }
        )

        // Add custom event handlers based on response received
        if (resp?.error) {
            alert(resp.error)
        } else if (!resp?.ok) {
            alert('An unknown error occurred!')
        } else {
            setEmail('')
            alert(`Email invite to ${email} sent successfully!`)
        }
        setLoading(false)
    }

    return (
        <main className='relative flex min-h-screen flex-col items-center justify-center p-24 bg-slate-300'>
            <div className="bg-white p-20 shadow-black shadow-sm rounded-sm">
                {/* Logo */}
                <div className="mb-8 text-center">
                    YOUR_LOGO
                </div>

                {/* Form */}
                <div className="w-full max-w-sm">
                    <h1 className="font-bold text-3xl mb-6 text-center">Invite User</h1>
                    <form onSubmit={handleSendInvite}>

                        <div className="relative">
                            <label
                                htmlFor="email"
                                className="bg-white absolute -top-2 left-2 text-xs px-1 font-medium"
                            >
                                Email Address
                            </label>
                            <input
                                type="email"
                                id="email"
                                name="email"
                                autoComplete='email'
                                placeholder="[email protected]"
                                required
                                value={email}
                                onChange={(e) => setEmail(e.target.value)}
                                className="block w-full border border-neutral-400 placeholder:text-neutral-400 py-2.5 px-5 rounded-sm"
                            />
                        </div>

                        <button
                            type="submit"
                            disabled={loading}
                            className="block w-full py-2.5 px-5 mt-4 bg-cyan-600 text-white text-center rounded-sm"
                        >
                            {loading ? 'Please Wait...' : 'Send Invite'}
                        </button>
                        <p className="text-center text-sm mt-4">
                            Return to the{' '}
                            <Link
                                href="/admin/dashboard"
                                className="inline-block text-cyan-600"
                            >Dashboard</Link>
                        </p>
                    </form>
                </div>
            </div>
        </main>
    )
}

Теперь ты куда-то попал! Ваше приложение позволяет пользователям регистрироваться, входить в систему и приглашать других пользователей. К сожалению, ваш URL-адрес обратного вызова отправляет пользователей на страницу, которой в настоящее время не существует, поэтому создайте ее прямо сейчас!

Создать страницу с приглашениями на просмотр

Чтобы ваши пользователи с приглашениями могли просматривать и принимать свои приглашения, создайте для них страницу «просмотр приглашений», чтобы они могли получить доступ к соответствующим записям приглашений. Создайте папку /view-invites внутри вашей папки /pages/admin и добавьте файл index.tsx. Идите вперед и используйте следующий код для базовой структуры страницы:

// pages/admin/view-invites/index.tsx

import Link from "next/link"
import { useState } from "react"

export default function ViewInvites() {

    const [invites, setInvites] = useState([])

    return (
        <main className='relative flex min-h-screen flex-col items-center justify-center p-24 bg-slate-300'>
            <div className="bg-white p-20 shadow-black shadow-sm rounded-sm">
                {/* Logo */}
                <div className="mb-8 text-center">
                    YOUR_LOGO
                </div>

                {/* Invites */}
                <div className="w-full max-w-sm">
                    <h1 className="font-bold text-3xl mb-6 text-center">View Invites</h1>
                    <div>
                        {
                            !invites.length ? (
                                <div>
                                    <p>You have not received any new invites.</p>
                                </div>
                            ) : (
                                <div>
                                    {/* Invite Card Component Will Go Here */}
                                </div>
                            )
                        }
                    </div>
                </div>
                <p className="text-center text-sm mt-4">
                    Return to the{' '}
                    <Link
                        href="/admin/dashboard"
                        className="inline-block text-cyan-600"
                    >Dashboard</Link>
                </p>
            </div>
        </main>
    )
}

На данный момент у вас есть страница, которая устанавливает переменную состояния «приглашения» в пустой массив и сопоставление этой переменной, которое будет выводить активные записи приглашений пользователя или абзац с надписью «Вы не получали новых приглашений», если нет приглашений. записи существуют. Следующим шагом будет добавление создания записи приглашения в поток авторизации по электронной почте, а затем извлечение этих данных на этой странице, чтобы пользователи могли просматривать и принимать/отклонять свои приглашения.

Создать модель БД InviteToken

Чтобы упростить организацию базы данных, начните с создания модели, которая будет представлять, как будут выглядеть ваши документы с токенами приглашения (значения, которые вы хотите использовать в своем приглашении). Для целей этого руководства не усложняйте работу со свойствами id, expires, team, InvitedBy и Invited — при необходимости вы можете расширять их для захвата и работы с дополнительными значениями в своем собственном приложении. Создайте папку /models в корневой папке вашего проекта. Затем создайте файл InviteToken.ts и добавьте следующий код:

// models/InviteToken.ts

import { ObjectId } from "mongodb"

export interface InviteToken {
    id?: ObjectId
    expires: Date
    team: string
    invitedBy: string
    invited: string
}

Создать конечную точку API приглашения

Затем создайте папку /invites внутри папки /pages/api, а затем создайте файл index.ts. Откройте файл index.ts и добавьте следующий код:

// pages/api/invites/index.ts

import { getServerSession } from "next-auth/next"
import { authOptions } from "@/pages/api/auth/[...nextauth]"
import { NextApiRequest, NextApiResponse } from "next"
import clientPromise from '@/lib/mongodb/client'
import { InviteToken } from "@/models/InviteToken"
import { ObjectId } from "mongodb"
const database = process.env.MONGODB_DB

export default async function inviteHandler(req: NextApiRequest, res: NextApiResponse) {
    const { method } = req
    const { email } = JSON.parse(req.body)
    const session = await getServerSession(req, res, authOptions)

    // If no session exists, return an error response and appropriate message
    if (!session?.user?.email)
        return res.status(401).json({ ok: false, message: 'Valid session not found.' })

    switch (method) {
        case 'POST':
            try {
                // Connect to MongoDB
                const client = await clientPromise
                const db = client.db(database)

                // Set expiration date
                const expirationDate = new Date()
                expirationDate.setDate(expirationDate.getDate() + 7)

                // Insert a new document into the invite_tokens collection
                const insert = await db.collection<InviteToken>('invite_tokens').insertOne({
                    expires: expirationDate,
                    team: session.user.email,
                    invitedBy: session.user.email,
                    invited: email,
                })

                // If insert acknowledgement is false, something went wrong and we should return an error response
                if (!insert.acknowledged)
                    return res.status(500).json({ ok: false, message: 'An error occurred during insert', insert })

                res.status(200).json({ ok: true, message: 'Invite posted successfully', insert })

            } catch (error) {
                return res.status(500).json({ ok: false, message: 'An unexpected error occurred', error })
            }

            break

        default:
            res.setHeader('Allow', ['POST'])
            res.status(405).end(`Method ${method} Not Allowed`)
    }
}

Примечание. Хотя мы добавили сюда значение «expires», вам нужно будет фактически настроить триггер MongoDB или аналогичный автоматический процесс, чтобы очистить эти токены с истекшим сроком действия, чтобы сделать их действительно ограниченными по времени. Мы не будем освещать это в этом прохождении. Кроме того, вы увидите, что значение «команда» в настоящее время представляет собой просто адрес электронной почты пользователя, отправившего приглашение; в вашем приложении у вас, скорее всего, будут другие значения, связанные с приглашающим пользователем, для использования в ваших записях приглашений.

Добавить Create Invite Token на страницу приглашения

Теперь, когда у вас есть конечная точка API для создания записей приглашений, вам нужно добавить функцию на страницу приглашения, чтобы инициировать команду POST для этой конечной точки. Откройте страницу «пригласить», добавьте функцию createInviteToken и соответствующим образом обновите функцию приглашенияHandler:

// pages/admin/invite/index.tsx

import { signIn } from "next-auth/react"
import Link from "next/link"
import { useState } from 'react'

export default function Invite() {

    // State Variables
    const [email, setEmail] = useState('')
    const [loading, setLoading] = useState(false)

    /**
     * * Create Invite Token
     * @dev Creates an invite token in the database
     * @returns JSON object
     */
    const createInviteToken = async () => 

        const resp = await fetch('/api/invites/', {
            method: 'POST',
            body: JSON.stringify({ email })
        })

        const respJson = await resp.json()

        return respJson
    }

    /**
     * * Handle Send Invite
     * @dev handler for more control over the send invite user experience
     * @param e formEvent
     */
    const handleSendInvite = async (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault()
        setLoading(true)

        // Sned the email invite request
        const resp = await signIn(
            'emailInvite',
            {
                email,
                callbackUrl: '/auth/accept-invite',
                redirect: false
            }
        )

        // Add custom event handlers based on response received
        if (resp?.error) {
            alert(resp.error)
        } else if (!resp?.ok) {
            alert('An unknown error occurred!')
        } else {
            // Create an invite token
            await createInviteToken()
            alert(`Email invite to ${email} sent successfully!`)
            setEmail('')
        }

        setLoading(false)
    }

    return (
        <main className='relative flex min-h-screen flex-col items-center justify-center p-24 bg-slate-300'>
            <div className="bg-white p-20 shadow-black shadow-sm rounded-sm">
                {/* Logo */}
                <div className="mb-8 text-center">
                    YOUR_LOGO
                </div>

                {/* Form */}
                <div className="w-full max-w-sm">
                    <h1 className="font-bold text-3xl mb-6 text-center">Invite User</h1>
                    <form onSubmit={handleSendInvite}>

                        <div className="relative">
                            <label
                                htmlFor="email"
                                className="bg-white absolute -top-2 left-2 text-xs px-1 font-medium"
                            >
                                Email Address
                            </label>
                            <input
                                type="email"
                                id="email"
                                name="email"
                                autoComplete='email'
                                placeholder="[email protected]"
                                required
                                value={email}
                                onChange={(e) => setEmail(e.target.value)}
                                className="block w-full border border-neutral-400 placeholder:text-neutral-400 py-2.5 px-5 rounded-sm"
                            />
                        </div>

                        <button
                            type="submit"
                            disabled={loading}
                            className="block w-full py-2.5 px-5 mt-4 bg-cyan-600 text-white text-center rounded-sm"
                        >
                            {loading ? 'Please Wait...' : 'Send Invite'}
                        </button>
                        <p className="text-center text-sm mt-4">
                            Return to the{' '}
                            <Link
                                href="/admin/dashboard"
                                className="inline-block text-cyan-600"
                            >Dashboard</Link>
                        </p>
                    </form>
                </div>
            </div>
        </main>
    )
}

После этого вы сможете отправить несколько приглашений! Войдите в свое приложение и отправьте приглашение на альтернативный адрес электронной почты (или с учетом действующих в настоящее время нестрогих ограничений вы также можете отправить приглашение самому себе).

Добавить получение приглашений в конечную точку Invite API

Теперь, когда у вас есть несколько записей токенов приглашения в вашей базе данных, откройте файл /pages/api/invites/index.ts и обновите конечную точку API, чтобы запрашивать записи приглашений на основе адреса электронной почты вошедшего пользователя, когда он получает запрос GET:

// pages/api/invite/index.ts

import { getServerSession } from "next-auth/next"
import { authOptions } from "@/pages/api/auth/[...nextauth]"
import { NextApiRequest, NextApiResponse } from "next"
import clientPromise from '@/lib/mongodb/client'
import { InviteToken } from "@/models/InviteToken"
import { ObjectId } from "mongodb"
const database = process.env.MONGODB_DB

export default async function inviteHandler(req: NextApiRequest, res: NextApiResponse) {
    const { method } = req
    const session = await getServerSession(req, res, authOptions)

    // If no session exists, return an error response and appropriate message
    if (!session?.user?.email)
        return res.status(401).json({ ok: false, message: 'Valid session not found.' })

    switch (method) {
        case 'POST':
            const { email } = JSON.parse(req.body)
            
            try {
                // Connect to MongoDB
                const client = await clientPromise
                const db = client.db(database)

                // Set expiration date
                const expirationDate = new Date()
                expirationDate.setDate(expirationDate.getDate() + 7)

                // Insert a new document into the invite_tokens collection
                const insert = await db.collection<InviteToken>('invite_tokens').insertOne({
                    expires: expirationDate,
                    team: session.user.email,
                    invitedBy: session.user.email,
                    invited: email,
                })

                // If insert acknowledgement is false, something went wrong and we should return an error response
                if (!insert.acknowledged)
                    return res.status(500).json({ ok: false, message: 'An error occurred during insert', insert })

                res.status(200).json({ ok: true, message: 'Invite posted successfully', insert })

            } catch (error) {
                return res.status(500).json({ ok: false, message: 'An unexpected error occurred', error })
            }

            break

        case 'GET':
            try {
                // Connect to MongoDB
                const client = await clientPromise
                const db = client.db(database)

                // Query session user's invite records from the invite_tokens collection
                const inviteRecords = await db.collection<InviteToken>('invite_tokens').find({ invited: session.user.email }).toArray()

                res.status(200).json({ ok: true, message: 'Invite records queried successfully', inviteRecords })

            } catch (error) {
                return res.status(500).json({ ok: false, message: 'An unexpected error occurred', error })
            }

            break

        default:
            res.setHeader('Allow', ['POST'])
            res.status(405).end(`Method ${method} Not Allowed`)
    }
}

Добавить функцию инициализации приглашений для просмотра страницы приглашений

Теперь, когда у вас есть конечная точка API для получения записей токенов приглашения, откройте файл /pages/admin/view-invites/index.tsx и соответствующим образом обновите код:

// pages/admin/view-invites/index.tsx

import Link from "next/link"
import { useEffect, useState } from "react"

interface InviteTokenRecord {
    id: string
    team: string
    invitedBy: string
    invited: string
}

export default function ViewInvites() {

    // State Variables
    const [invites, setInvites] = useState<InviteTokenRecord[] | []>([])

    const acceptInvite = async (id: string) => {
        // Code to handle accepted invites
        // e.g., add team to user record in users collection and update invite record to indicate acceptance or delete
    }

    const rejectInvite = async (id: string) => {
        // Code to handle rejected invites
        // e.g., update invite record to indicate rejection or delete
    }

    // Set initial invite token records
    useEffect(() => {
        const initializeInvites = async () => {
            try {
                const resp = await fetch('/api/invites')
                const respJson = await resp.json()

                setInvites(respJson.inviteRecords)
            } catch (error) {
                alert('An unexpected error occurred')
                console.error(error)
            }

        }
        initializeInvites()
    }, [])

    return (
        <main className='relative flex min-h-screen flex-col items-center justify-center p-24 bg-slate-300'>
            <div className="bg-white p-20 shadow-black shadow-sm rounded-sm">
                {/* Logo */}
                <div className="mb-8 text-center">
                    YOUR_LOGO
                </div>

                {/* Invites */}
                <div className="w-full max-w-sm">
                    <h1 className="font-bold text-3xl mb-6 text-center">View Invites</h1>

                    {
                        !invites.length ? (
                            <p>You have not received any new invites.</p>
                        ) : (
                            <div>
                                {invites.map((invite, i) => (
                                    <div
                                        className="bg-slate-300 p-8" 
                                        key={i}>
                                        <ul>
                                            <li>Team: {invite.invitedBy}</li>
                                            <li>Invited By: {invite.invitedBy}</li>
                                        </ul>

                                        <div className="flex items-center justify-center gap-4 mt-8">
                                            <button className="bg-cyan-500 text-white py-1.5 px-5 rounded-sm" onClick={() => acceptInvite(invite?.id)}>Accept</button>
                                            <button className="bg-slate-800 text-white py-1.5 px-5 rounded-sm" onClick={() => rejectInvite(invite?.id)}>Reject</button>
                                        </div>
                                    </div>
                                ))}
                            </div>
                        )
                    }

                </div>
                <p className="text-center text-sm mt-4">
                    Return to the{' '}
                    <Link
                        href="/admin/dashboard"
                        className="inline-block text-cyan-600"
                    >Dashboard</Link>
                </p>
            </div>
        </main>
    )
}

Заключение

ХОРОШО! На этом этапе у вас должно быть приложение, позволяющее пользователям регистрироваться (регистрировать), входить в систему и приглашать других пользователей. Ваш процесс аутентификации приглашения по электронной почте теперь также включает создание токенов приглашения, которые вы можете использовать для хранения информации о приглашающем и приглашенном, а также разрешить пользователям принимать/отклонять свои токены приглашения. Мы продолжим и остановимся здесь, так как мы надеемся, что это обеспечит достаточную стартовую площадку для продолжения расширения функциональности «пригласить пользователя»! Дальнейшее расширение, которое следует рассмотреть, может включать следующее:

  • Добавьте ссылку на панель инструментов для доступа к странице «Просмотр приглашений».
  • Разверните конечную точку API приглашения и страницу «Просмотр приглашений», чтобы запросить в базе данных маркеры приглашения, где вошедший в систему пользователь существует либо как «приглашенный», либо как «приглашенный», что позволяет просматривать ОТПРАВЛЕННЫЕ приглашения, а также ПОЛУЧЕННЫЕ приглашения.
  • Добавьте сдержки и противовесы, чтобы предотвратить дублирование приглашений или лучше управлять ими.
  • Расширьте информацию, хранящуюся и используемую в записях токенов приглашения.

Я надеюсь, что это прохождение соединит некоторые точки или зажжет некоторые идеи для вас при использовании NextAuth и рассмотрении функциональности стиля Invite!