
Я провел немало поисков и изо всех сил пытался найти отличный поток аутентификации «Пригласить пользователя» через поставщика электронной почты для 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, "​.")
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!