diff --git a/docs/data/toolpad/core/components/sign-in-page/MagicLinkAlertSignInPage.js b/docs/data/toolpad/core/components/sign-in-page/MagicLinkAlertSignInPage.js
new file mode 100644
index 00000000000..384a818f17f
--- /dev/null
+++ b/docs/data/toolpad/core/components/sign-in-page/MagicLinkAlertSignInPage.js
@@ -0,0 +1,31 @@
+import * as React from 'react';
+import { SignInPage } from '@toolpad/core/SignInPage';
+import { AppProvider } from '@toolpad/core/AppProvider';
+import { useTheme } from '@mui/material/styles';
+
+const providers = [{ id: 'nodemailer', name: 'Email' }];
+
+const signIn = async (provider) => {
+ const promise = new Promise((resolve) => {
+ setTimeout(() => {
+ console.log(`Sign in with ${provider.id}`);
+ // preview-start
+ resolve({
+ success: 'Check your email for a verification link.',
+ });
+ // preview-end
+ }, 500);
+ });
+ return promise;
+};
+
+export default function MagicLinkAlertSignInPage() {
+ const theme = useTheme();
+ return (
+ // preview-start
+
+
+
+ // preview-end
+ );
+}
diff --git a/docs/data/toolpad/core/components/sign-in-page/MagicLinkAlertSignInPage.tsx b/docs/data/toolpad/core/components/sign-in-page/MagicLinkAlertSignInPage.tsx
new file mode 100644
index 00000000000..760101ef225
--- /dev/null
+++ b/docs/data/toolpad/core/components/sign-in-page/MagicLinkAlertSignInPage.tsx
@@ -0,0 +1,40 @@
+import * as React from 'react';
+import {
+ AuthProvider,
+ SignInPage,
+ SupportedAuthProvider,
+ AuthResponse,
+} from '@toolpad/core/SignInPage';
+import { AppProvider } from '@toolpad/core/AppProvider';
+import { useTheme } from '@mui/material/styles';
+
+const providers: { id: SupportedAuthProvider; name: string }[] = [
+ { id: 'nodemailer', name: 'Email' },
+];
+
+const signIn: (provider: AuthProvider) => Promise = async (
+ provider,
+) => {
+ const promise = new Promise((resolve) => {
+ setTimeout(() => {
+ console.log(`Sign in with ${provider.id}`);
+ // preview-start
+ resolve({
+ success: 'Check your email for a verification link.',
+ });
+ // preview-end
+ }, 500);
+ });
+ return promise;
+};
+
+export default function MagicLinkAlertSignInPage() {
+ const theme = useTheme();
+ return (
+ // preview-start
+
+
+
+ // preview-end
+ );
+}
diff --git a/docs/data/toolpad/core/components/sign-in-page/MagicLinkAlertSignInPage.tsx.preview b/docs/data/toolpad/core/components/sign-in-page/MagicLinkAlertSignInPage.tsx.preview
new file mode 100644
index 00000000000..074047956fc
--- /dev/null
+++ b/docs/data/toolpad/core/components/sign-in-page/MagicLinkAlertSignInPage.tsx.preview
@@ -0,0 +1,9 @@
+resolve({
+ success: 'Check your email for a verification link.',
+});
+
+// ...
+
+
+
+
\ No newline at end of file
diff --git a/docs/data/toolpad/core/components/sign-in-page/MagicLinkSignInPage.js b/docs/data/toolpad/core/components/sign-in-page/MagicLinkSignInPage.js
new file mode 100644
index 00000000000..6a7dd19e672
--- /dev/null
+++ b/docs/data/toolpad/core/components/sign-in-page/MagicLinkSignInPage.js
@@ -0,0 +1,30 @@
+import * as React from 'react';
+import { SignInPage } from '@toolpad/core/SignInPage';
+import { AppProvider } from '@toolpad/core/AppProvider';
+import { useTheme } from '@mui/material/styles';
+
+// preview-start
+const providers = [{ id: 'nodemailer', name: 'Email' }];
+
+// preview-end
+
+const signIn = async (provider) => {
+ const promise = new Promise((resolve) => {
+ setTimeout(() => {
+ console.log(`Sign in with ${provider.id}`);
+ resolve();
+ }, 500);
+ });
+ return promise;
+};
+
+export default function MagicLinkSignInPage() {
+ const theme = useTheme();
+ return (
+ // preview-start
+
+
+
+ // preview-end
+ );
+}
diff --git a/docs/data/toolpad/core/components/sign-in-page/MagicLinkSignInPage.tsx b/docs/data/toolpad/core/components/sign-in-page/MagicLinkSignInPage.tsx
new file mode 100644
index 00000000000..6565c3faca1
--- /dev/null
+++ b/docs/data/toolpad/core/components/sign-in-page/MagicLinkSignInPage.tsx
@@ -0,0 +1,35 @@
+import * as React from 'react';
+import {
+ AuthProvider,
+ SignInPage,
+ SupportedAuthProvider,
+} from '@toolpad/core/SignInPage';
+import { AppProvider } from '@toolpad/core/AppProvider';
+import { useTheme } from '@mui/material/styles';
+
+// preview-start
+const providers: { id: SupportedAuthProvider; name: string }[] = [
+ { id: 'nodemailer', name: 'Email' },
+];
+// preview-end
+
+const signIn: (provider: AuthProvider) => void = async (provider) => {
+ const promise = new Promise((resolve) => {
+ setTimeout(() => {
+ console.log(`Sign in with ${provider.id}`);
+ resolve();
+ }, 500);
+ });
+ return promise;
+};
+
+export default function MagicLinkSignInPage() {
+ const theme = useTheme();
+ return (
+ // preview-start
+
+
+
+ // preview-end
+ );
+}
diff --git a/docs/data/toolpad/core/components/sign-in-page/MagicLinkSignInPage.tsx.preview b/docs/data/toolpad/core/components/sign-in-page/MagicLinkSignInPage.tsx.preview
new file mode 100644
index 00000000000..4d6d5fff426
--- /dev/null
+++ b/docs/data/toolpad/core/components/sign-in-page/MagicLinkSignInPage.tsx.preview
@@ -0,0 +1,9 @@
+const providers: { id: SupportedAuthProvider; name: string }[] = [
+ { id: 'nodemailer', name: 'Email' },
+];
+
+// ...
+
+
+
+
\ No newline at end of file
diff --git a/docs/data/toolpad/core/components/sign-in-page/OAuthSignInPage.js b/docs/data/toolpad/core/components/sign-in-page/OAuthSignInPage.js
index 7c68f46bef6..4a2c5fbf1b5 100644
--- a/docs/data/toolpad/core/components/sign-in-page/OAuthSignInPage.js
+++ b/docs/data/toolpad/core/components/sign-in-page/OAuthSignInPage.js
@@ -2,6 +2,7 @@ import * as React from 'react';
import { AppProvider } from '@toolpad/core/AppProvider';
import { SignInPage } from '@toolpad/core/SignInPage';
import { useTheme } from '@mui/material/styles';
+
// preview-start
const providers = [
{ id: 'github', name: 'GitHub' },
diff --git a/docs/data/toolpad/core/components/sign-in-page/OAuthSignInPage.tsx b/docs/data/toolpad/core/components/sign-in-page/OAuthSignInPage.tsx
index 0ce2d8f081a..b32b9ed5750 100644
--- a/docs/data/toolpad/core/components/sign-in-page/OAuthSignInPage.tsx
+++ b/docs/data/toolpad/core/components/sign-in-page/OAuthSignInPage.tsx
@@ -2,6 +2,7 @@ import * as React from 'react';
import { AppProvider } from '@toolpad/core/AppProvider';
import { SignInPage, type AuthProvider } from '@toolpad/core/SignInPage';
import { useTheme } from '@mui/material/styles';
+
// preview-start
const providers = [
{ id: 'github', name: 'GitHub' },
diff --git a/docs/data/toolpad/core/components/sign-in-page/sign-in-page.md b/docs/data/toolpad/core/components/sign-in-page/sign-in-page.md
index cb8e7084712..4899f9708ee 100644
--- a/docs/data/toolpad/core/components/sign-in-page/sign-in-page.md
+++ b/docs/data/toolpad/core/components/sign-in-page/sign-in-page.md
@@ -22,7 +22,7 @@ The `SignInPage` component can be set up with an OAuth provider by passing in a
:::info
-The following providers are supported and maintained by default:
+The following OAuth providers are supported and maintained by default:
- Google
- GitHub
@@ -44,11 +44,29 @@ The following providers are supported and maintained by default:
- Twitch
- Discord
- Keycloak
-- Credentials (username/password)
Find details on how to set up each provider in the [Auth.js documentation](https://authjs.dev/getting-started/authentication/oauth).
:::
+## Magic Link
+
+The `SignIn` page component supports magic links. To enable this, you have to set up a provider such as Auth.js NodeMailer. See more details in the Auth.js docs on [database setup for email](https://authjs.dev/getting-started/authentication/email) and [Nodemailer configuration](https://authjs.dev/getting-started/providers/nodemailer/).
+
+To render a magic link form, pass in a provider with `nodemailer` as the `id` property.
+
+{{"demo": "MagicLinkSignInPage.js", "iframe": true, "height": 400}}
+
+### Alerts
+
+The `SignInPage` component can display a success alert if the email is sent successfully. You can enable this by passing a `success` property in the
+response object of the `signIn` prop.
+
+{{"demo": "MagicLinkAlertSignInPage.js", "iframe": true, "height": 400}}
+
+:::info
+Check out the complete [Next.js Auth.js Magic Link example](https://github.com/mui/mui-toolpad/tree/master/examples/core-auth-nextjs-email/) example for a working implementation of a magic link sign-in page with Auth.js, Nodemailer, Prisma and PostgreSQL.
+:::
+
## Passkey
The `SignInPage` component can be set up to use [Passkeys](https://passkeys.dev) by passing in a provider with `passkey` as the `id`:
@@ -64,7 +82,7 @@ The [Toolpad Core Passkey example app](https://github.com/mui/mui-toolpad/tree/m
## Credentials
:::warning
-It is recommended to use the OAuth or Passkey provider for more robust maintenance, support, and security.
+The Credentials provider is not the most secure way to authenticate users. It's recommended to use any of the other providers for a more robust solution.
:::
To render a username password form, pass in a provider with `credentials` as the `id` property. The `signIn` function accepts a `formData` parameter in this case.
@@ -231,4 +249,4 @@ The `SignInPage` component has versions with different layouts for authenticatio
## 🚧 Other authentication Flows
-The `SignInPage` will be accompanied by other components to allow users to sign up, verify emails and reset passwords. This is in progress.
+Besides the `SignInPage` , the team is planning work on several other components that enable new workflows such as [sign up](https://github.com/mui/toolpad/issues/4068) and [password reset](https://github.com/mui/toolpad/issues/4265).
diff --git a/docs/pages/toolpad/core/api/sign-in-page.json b/docs/pages/toolpad/core/api/sign-in-page.json
index f1f2069f591..aa3d713e361 100644
--- a/docs/pages/toolpad/core/api/sign-in-page.json
+++ b/docs/pages/toolpad/core/api/sign-in-page.json
@@ -3,7 +3,7 @@
"providers": {
"type": {
"name": "arrayOf",
- "description": "Array<{ id: 'apple'
| 'auth0'
| 'cognito'
| 'credentials'
| 'discord'
| 'facebook'
| 'fusionauth'
| 'github'
| 'gitlab'
| 'google'
| 'instagram'
| 'keycloak'
| 'line'
| 'linkedin'
| 'microsoft-entra-id'
| 'okta'
| 'passkey'
| 'slack'
| 'spotify'
| 'tiktok'
| 'twitch'
| 'twitter', name: string }>"
+ "description": "Array<{ id: 'apple'
| 'auth0'
| 'cognito'
| 'credentials'
| 'discord'
| 'facebook'
| 'fusionauth'
| 'github'
| 'gitlab'
| 'google'
| 'instagram'
| 'keycloak'
| 'line'
| 'linkedin'
| 'microsoft-entra-id'
| 'nodemailer'
| 'okta'
| 'passkey'
| 'slack'
| 'spotify'
| 'tiktok'
| 'twitch'
| 'twitter', name: string }>"
},
"default": "[]"
},
diff --git a/examples/core-auth-nextjs-email/.eslintrc.json b/examples/core-auth-nextjs-email/.eslintrc.json
new file mode 100644
index 00000000000..bffb357a712
--- /dev/null
+++ b/examples/core-auth-nextjs-email/.eslintrc.json
@@ -0,0 +1,3 @@
+{
+ "extends": "next/core-web-vitals"
+}
diff --git a/examples/core-auth-nextjs-email/.gitignore b/examples/core-auth-nextjs-email/.gitignore
new file mode 100644
index 00000000000..68c5d18f00d
--- /dev/null
+++ b/examples/core-auth-nextjs-email/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
diff --git a/examples/core-auth-nextjs-email/Dockerfile b/examples/core-auth-nextjs-email/Dockerfile
new file mode 100644
index 00000000000..a79b85781fd
--- /dev/null
+++ b/examples/core-auth-nextjs-email/Dockerfile
@@ -0,0 +1,45 @@
+# Use Node.js 20 Alpine as the base image
+FROM node:20-alpine AS builder
+
+# Set working directory
+WORKDIR /app
+
+# Copy package.json and package-lock.json
+COPY package*.json ./
+
+# Install dependencies
+RUN npm install
+
+# Copy all files
+COPY . .
+
+# Build the Next.js app
+RUN npm run build
+
+# Start a new stage for a smaller final image
+FROM node:20-alpine AS runner
+
+WORKDIR /app
+
+# Copy built assets from the builder stage
+COPY --from=builder /app/.next ./.next
+COPY --from=builder /app/node_modules ./node_modules
+COPY --from=builder /app/package.json ./package.json
+COPY --from=builder /app/src/prisma ./prisma
+
+# Set environment variables
+ENV NODE_ENV production
+ENV PORT 3000
+
+# Copy the entrypoint script
+COPY entrypoint.sh /entrypoint.sh
+RUN chmod +x /entrypoint.sh
+
+# Set the entrypoint
+ENTRYPOINT ["/entrypoint.sh"]
+
+# Expose the port Next.js runs on
+EXPOSE 3000
+
+# Run the Next.js app
+CMD ["npm", "start"]
diff --git a/examples/core-auth-nextjs-email/README.md b/examples/core-auth-nextjs-email/README.md
new file mode 100644
index 00000000000..1e5da9d8c8d
--- /dev/null
+++ b/examples/core-auth-nextjs-email/README.md
@@ -0,0 +1,34 @@
+# Toolpad Core Next.js App Router app with email provider
+
+This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
+
+## Getting Started
+
+First, run the development server:
+
+```bash
+npm run dev
+# or
+yarn dev
+# or
+pnpm dev
+# or
+bun dev
+```
+
+Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+
+## Learn More
+
+To learn more about Next.js, take a look at the following resources:
+
+- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
+- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+
+You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
+
+## Deploy on Vercel
+
+The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
+
+Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.
diff --git a/examples/core-auth-nextjs-email/docker-compose.yml b/examples/core-auth-nextjs-email/docker-compose.yml
new file mode 100644
index 00000000000..29b8ce23cc1
--- /dev/null
+++ b/examples/core-auth-nextjs-email/docker-compose.yml
@@ -0,0 +1,52 @@
+version: '3.8'
+
+services:
+ app:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ ports:
+ - '3000:3000'
+ environment:
+ - DATABASE_URL=${DATABASE_URL}
+ - AUTH_URL=${AUTH_URL}
+ - AUTH_TRUST_HOST=true
+ - NODE_ENV=production
+ - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID}
+ - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET}
+ - AUTH_SECRET=${AUTH_SECRET}
+ - EMAIL_SERVER_HOST=${EMAIL_SERVER_HOST}
+ - EMAIL_SERVER_PORT=${EMAIL_SERVER_PORT}
+ - EMAIL_SERVER_USER=${EMAIL_SERVER_USER}
+ - EMAIL_SERVER_PASSWORD=${EMAIL_SERVER_PASSWORD}
+ - EMAIL_FROM=${EMAIL_FROM}
+ depends_on:
+ db:
+ condition: service_healthy
+ networks:
+ - app-network
+
+ db:
+ image: postgres:13
+ environment:
+ - POSTGRES_USER=${POSTGRES_USER}
+ - POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
+ - POSTGRES_DB=${POSTGRES_DB}
+ ports:
+ - '${POSTGRES_PORT}:5432'
+ volumes:
+ - postgres_data:/var/lib/postgresql/data
+ healthcheck:
+ test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}']
+ interval: 5s
+ timeout: 5s
+ retries: 5
+ networks:
+ - app-network
+
+volumes:
+ postgres_data:
+
+networks:
+ app-network:
+ driver: bridge
diff --git a/examples/core-auth-nextjs-email/entrypoint.sh b/examples/core-auth-nextjs-email/entrypoint.sh
new file mode 100644
index 00000000000..3b20a40d898
--- /dev/null
+++ b/examples/core-auth-nextjs-email/entrypoint.sh
@@ -0,0 +1,14 @@
+#!/bin/sh
+
+# Wait for the database to be ready
+until nc -z db 5432; do
+ echo "Waiting for database to be ready..."
+ sleep 2
+done
+
+# Run migrations
+npx prisma migrate deploy
+npx prisma generate
+
+# Start the application
+exec npm start
\ No newline at end of file
diff --git a/examples/core-auth-nextjs-email/next-env.d.ts b/examples/core-auth-nextjs-email/next-env.d.ts
new file mode 100644
index 00000000000..40c3d68096c
--- /dev/null
+++ b/examples/core-auth-nextjs-email/next-env.d.ts
@@ -0,0 +1,5 @@
+///
+///
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
diff --git a/examples/core-auth-nextjs-email/next.config.mjs b/examples/core-auth-nextjs-email/next.config.mjs
new file mode 100644
index 00000000000..4678774e6d6
--- /dev/null
+++ b/examples/core-auth-nextjs-email/next.config.mjs
@@ -0,0 +1,4 @@
+/** @type {import('next').NextConfig} */
+const nextConfig = {};
+
+export default nextConfig;
diff --git a/examples/core-auth-nextjs-email/package.json b/examples/core-auth-nextjs-email/package.json
new file mode 100644
index 00000000000..384cc7c8a26
--- /dev/null
+++ b/examples/core-auth-nextjs-email/package.json
@@ -0,0 +1,33 @@
+{
+ "name": "playground-nextjs-email",
+ "version": "0.5.2",
+ "private": true,
+ "scripts": {
+ "dev": "next dev",
+ "lint": "next lint"
+ },
+ "dependencies": {
+ "@emotion/react": "^11",
+ "@emotion/styled": "^11",
+ "@mui/icons-material": "^6",
+ "@mui/material": "^6",
+ "@mui/material-nextjs": "^6",
+ "@toolpad/core": "latest",
+ "@prisma/client": "^5",
+ "prisma": "^5",
+ "@auth/prisma-adapter": "^2",
+ "next": "^14",
+ "next-auth": "5.0.0-beta.20",
+ "nodemailer": "^6",
+ "react": "^18",
+ "react-dom": "^18"
+ },
+ "devDependencies": {
+ "typescript": "^5",
+ "@types/node": "^20",
+ "@types/react": "^18",
+ "@types/react-dom": "^18",
+ "eslint": "^8",
+ "eslint-config-next": "^14"
+ }
+}
diff --git a/examples/core-auth-nextjs-email/src/app/(dashboard)/layout.tsx b/examples/core-auth-nextjs-email/src/app/(dashboard)/layout.tsx
new file mode 100644
index 00000000000..e481f628f1c
--- /dev/null
+++ b/examples/core-auth-nextjs-email/src/app/(dashboard)/layout.tsx
@@ -0,0 +1,11 @@
+import * as React from 'react';
+import { DashboardLayout } from '@toolpad/core/DashboardLayout';
+import { PageContainer } from '@toolpad/core/PageContainer';
+
+export default function DashboardPagesLayout(props: { children: React.ReactNode }) {
+ return (
+
+ {props.children}
+
+ );
+}
diff --git a/examples/core-auth-nextjs-email/src/app/(dashboard)/orders/page.tsx b/examples/core-auth-nextjs-email/src/app/(dashboard)/orders/page.tsx
new file mode 100644
index 00000000000..467e27cc6b3
--- /dev/null
+++ b/examples/core-auth-nextjs-email/src/app/(dashboard)/orders/page.tsx
@@ -0,0 +1,19 @@
+import * as React from 'react';
+import Typography from '@mui/material/Typography';
+import { redirect } from 'next/navigation';
+import { headers } from 'next/headers';
+import { auth } from '../../../auth';
+
+export default async function OrdersPage() {
+ const session = await auth();
+ const currentUrl = headers().get('referer') || 'http://localhost:3000';
+
+ if (!session) {
+ // Get the current URL to redirect to signIn with `callbackUrl`
+ const redirectUrl = new URL('/auth/signin', currentUrl);
+ redirectUrl.searchParams.set('callbackUrl', currentUrl);
+
+ redirect(redirectUrl.toString());
+ }
+ return Welcome to the Toolpad orders!;
+}
diff --git a/examples/core-auth-nextjs-email/src/app/(dashboard)/page.tsx b/examples/core-auth-nextjs-email/src/app/(dashboard)/page.tsx
new file mode 100644
index 00000000000..5e100e4e4f6
--- /dev/null
+++ b/examples/core-auth-nextjs-email/src/app/(dashboard)/page.tsx
@@ -0,0 +1,23 @@
+import * as React from 'react';
+import Typography from '@mui/material/Typography';
+import { redirect } from 'next/navigation';
+import { headers } from 'next/headers';
+import { auth } from '../../auth';
+
+export default async function HomePage() {
+ const session = await auth();
+ const currentUrl = headers().get('referer') || 'http://localhost:3000';
+
+ if (!session) {
+ // Get the current URL to redirect to signIn with `callbackUrl`
+ const redirectUrl = new URL('/auth/signin', currentUrl);
+ redirectUrl.searchParams.set('callbackUrl', currentUrl);
+
+ redirect(redirectUrl.toString());
+ }
+ return (
+
+ Welcome to Toolpad, {session?.user?.name || session?.user?.email || 'User'}!
+
+ );
+}
diff --git a/examples/core-auth-nextjs-email/src/app/api/auth/[...nextauth]/route.ts b/examples/core-auth-nextjs-email/src/app/api/auth/[...nextauth]/route.ts
new file mode 100644
index 00000000000..ca225652075
--- /dev/null
+++ b/examples/core-auth-nextjs-email/src/app/api/auth/[...nextauth]/route.ts
@@ -0,0 +1,3 @@
+import { handlers } from '../../../../auth';
+
+export const { GET, POST } = handlers;
diff --git a/examples/core-auth-nextjs-email/src/app/auth/signin/actions.ts b/examples/core-auth-nextjs-email/src/app/auth/signin/actions.ts
new file mode 100644
index 00000000000..7672bd32886
--- /dev/null
+++ b/examples/core-auth-nextjs-email/src/app/auth/signin/actions.ts
@@ -0,0 +1,47 @@
+'use server';
+import { AuthError } from 'next-auth';
+import type { AuthProvider } from '@toolpad/core';
+import { signIn as signInAction } from '../../../auth';
+
+async function signIn(provider: AuthProvider, formData: FormData, callbackUrl?: string) {
+ try {
+ return await signInAction(provider.id, {
+ ...(formData && { email: formData.get('email'), password: formData.get('password') }),
+ redirectTo: callbackUrl ?? '/',
+ });
+ } catch (error) {
+ // The desired flow for successful sign in in all cases
+ // and unsuccessful sign in for OAuth providers will cause a `redirect`,
+ // and `redirect` is a throwing function, so we need to re-throw
+ // to allow the redirect to happen
+ // Source: https://github.com/vercel/next.js/issues/49298#issuecomment-1542055642
+ // Detect a `NEXT_REDIRECT` error and re-throw it
+ if (error instanceof Error && error.message === 'NEXT_REDIRECT') {
+ // For the nodemailer provider, we want to return a success message
+ // instead of redirecting to a `verify-request` page
+ if (provider.id === 'nodemailer' && (error as any).digest?.includes('verify-request')) {
+ return {
+ success: 'Check your email for a verification link.',
+ };
+ }
+ throw error;
+ }
+ // Handle Auth.js errors
+ if (error instanceof AuthError) {
+ return {
+ error:
+ error.type === 'CredentialsSignin'
+ ? 'Invalid credentials.'
+ : 'An error with Auth.js occurred.',
+ type: error.type,
+ };
+ }
+ // An error boundary must exist to handle unknown errors
+ return {
+ error: 'Something went wrong.',
+ type: 'UnknownError',
+ };
+ }
+}
+
+export default signIn;
diff --git a/examples/core-auth-nextjs-email/src/app/auth/signin/page.tsx b/examples/core-auth-nextjs-email/src/app/auth/signin/page.tsx
new file mode 100644
index 00000000000..e1838f9807e
--- /dev/null
+++ b/examples/core-auth-nextjs-email/src/app/auth/signin/page.tsx
@@ -0,0 +1,12 @@
+import * as React from 'react';
+import { SignInPage } from '@toolpad/core/SignInPage';
+import { providerMap } from '../../../auth';
+import signIn from './actions';
+
+export default function SignIn() {
+ return (
+
+ ;
+
+ );
+}
diff --git a/examples/core-auth-nextjs-email/src/app/layout.tsx b/examples/core-auth-nextjs-email/src/app/layout.tsx
new file mode 100644
index 00000000000..96a76406401
--- /dev/null
+++ b/examples/core-auth-nextjs-email/src/app/layout.tsx
@@ -0,0 +1,57 @@
+import * as React from 'react';
+import { AppProvider } from '@toolpad/core/nextjs';
+import { AppRouterCacheProvider } from '@mui/material-nextjs/v14-appRouter';
+import DashboardIcon from '@mui/icons-material/Dashboard';
+import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
+import type { Navigation } from '@toolpad/core';
+import { SessionProvider, signIn, signOut } from 'next-auth/react';
+import { auth } from '../auth';
+
+const NAVIGATION: Navigation = [
+ {
+ kind: 'header',
+ title: 'Main items',
+ },
+ {
+ segment: '',
+ title: 'Dashboard',
+ icon: ,
+ },
+ {
+ segment: 'orders',
+ title: 'Orders',
+ icon: ,
+ },
+];
+
+const BRANDING = {
+ title: 'My Toolpad Core Next.js App',
+};
+
+const AUTHENTICATION = {
+ signIn,
+ signOut,
+};
+
+export default async function RootLayout(props: { children: React.ReactNode }) {
+ const session = await auth();
+
+ return (
+
+
+
+
+
+ {props.children}
+
+
+
+
+
+ );
+}
diff --git a/examples/core-auth-nextjs-email/src/app/public/layout.tsx b/examples/core-auth-nextjs-email/src/app/public/layout.tsx
new file mode 100644
index 00000000000..5dee163b753
--- /dev/null
+++ b/examples/core-auth-nextjs-email/src/app/public/layout.tsx
@@ -0,0 +1,11 @@
+import * as React from 'react';
+import { DashboardLayout } from '@toolpad/core/DashboardLayout';
+import { PageContainer } from '@toolpad/core/PageContainer';
+
+export default async function DashboardPagesLayout(props: { children: React.ReactNode }) {
+ return (
+
+ {props.children}
+
+ );
+}
diff --git a/examples/core-auth-nextjs-email/src/app/public/page.tsx b/examples/core-auth-nextjs-email/src/app/public/page.tsx
new file mode 100644
index 00000000000..9eb1ae52478
--- /dev/null
+++ b/examples/core-auth-nextjs-email/src/app/public/page.tsx
@@ -0,0 +1,6 @@
+import * as React from 'react';
+import Typography from '@mui/material/Typography';
+
+export default async function HomePage() {
+ return Public page;
+}
diff --git a/examples/core-auth-nextjs-email/src/auth.ts b/examples/core-auth-nextjs-email/src/auth.ts
new file mode 100644
index 00000000000..af08addd63f
--- /dev/null
+++ b/examples/core-auth-nextjs-email/src/auth.ts
@@ -0,0 +1,66 @@
+import NextAuth from 'next-auth';
+import GitHub from 'next-auth/providers/github';
+
+import Nodemailer from 'next-auth/providers/nodemailer';
+import { PrismaAdapter } from '@auth/prisma-adapter';
+import type { Provider } from 'next-auth/providers';
+import { prisma } from './prisma';
+
+const providers: Provider[] = [
+ GitHub({
+ clientId: process.env.GITHUB_CLIENT_ID,
+ clientSecret: process.env.GITHUB_CLIENT_SECRET,
+ }),
+ Nodemailer({
+ server: {
+ host: process.env.EMAIL_SERVER_HOST,
+ port: process.env.EMAIL_SERVER_PORT,
+ auth: {
+ user: process.env.EMAIL_SERVER_USER,
+ pass: process.env.EMAIL_SERVER_PASSWORD,
+ },
+ secure: true,
+ },
+ from: process.env.EMAIL_FROM,
+ }),
+];
+
+export const providerMap = providers.map((provider) => {
+ if (typeof provider === 'function') {
+ const providerData = provider();
+ return {
+ id: providerData.id,
+ name: providerData.name,
+ };
+ }
+ return { id: provider.id, name: provider.name };
+});
+
+if (!process.env.GITHUB_CLIENT_ID) {
+ console.warn('Missing environment variable "GITHUB_CLIENT_ID"');
+}
+if (!process.env.GITHUB_CLIENT_SECRET) {
+ console.warn('Missing environment variable "GITHUB_CLIENT_ID"');
+}
+
+export const { handlers, auth, signIn, signOut } = NextAuth({
+ providers,
+ adapter: PrismaAdapter(prisma),
+ session: { strategy: 'jwt' },
+ secret: process.env.AUTH_SECRET,
+ pages: {
+ signIn: '/auth/signin',
+ },
+ callbacks: {
+ authorized({ auth: session, request: { nextUrl } }) {
+ const isLoggedIn = !!session?.user;
+ const isPublicPage = nextUrl.pathname.startsWith('/public');
+
+ if (isPublicPage || isLoggedIn) {
+ return true;
+ }
+
+ return false; // Redirect unauthenticated users to login page
+ },
+ },
+});
diff --git a/examples/core-auth-nextjs-email/src/prisma.ts b/examples/core-auth-nextjs-email/src/prisma.ts
new file mode 100644
index 00000000000..ee2f97d763c
--- /dev/null
+++ b/examples/core-auth-nextjs-email/src/prisma.ts
@@ -0,0 +1,9 @@
+import { PrismaClient } from '@prisma/client';
+
+const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
+
+export const prisma = globalForPrisma.prisma || new PrismaClient();
+
+if (process.env.NODE_ENV !== 'production') {
+ globalForPrisma.prisma = prisma;
+}
diff --git a/examples/core-auth-nextjs-email/src/prisma/migrations/20240913094851_init/migration.sql b/examples/core-auth-nextjs-email/src/prisma/migrations/20240913094851_init/migration.sql
new file mode 100644
index 00000000000..9a11d832759
--- /dev/null
+++ b/examples/core-auth-nextjs-email/src/prisma/migrations/20240913094851_init/migration.sql
@@ -0,0 +1,61 @@
+-- CreateTable
+CREATE TABLE "User" (
+ "id" TEXT NOT NULL,
+ "name" TEXT,
+ "email" TEXT NOT NULL,
+ "emailVerified" TIMESTAMP(3),
+ "image" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "User_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "Account" (
+ "userId" TEXT NOT NULL,
+ "type" TEXT NOT NULL,
+ "provider" TEXT NOT NULL,
+ "providerAccountId" TEXT NOT NULL,
+ "refresh_token" TEXT,
+ "access_token" TEXT,
+ "expires_at" INTEGER,
+ "token_type" TEXT,
+ "scope" TEXT,
+ "id_token" TEXT,
+ "session_state" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Account_pkey" PRIMARY KEY ("provider","providerAccountId")
+);
+
+-- CreateTable
+CREATE TABLE "Session" (
+ "sessionToken" TEXT NOT NULL,
+ "userId" TEXT NOT NULL,
+ "expires" TIMESTAMP(3) NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL
+);
+
+-- CreateTable
+CREATE TABLE "VerificationToken" (
+ "identifier" TEXT NOT NULL,
+ "token" TEXT NOT NULL,
+ "expires" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "VerificationToken_pkey" PRIMARY KEY ("identifier","token")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
+
+-- AddForeignKey
+ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/examples/core-auth-nextjs-email/src/prisma/migrations/migration_lock.toml b/examples/core-auth-nextjs-email/src/prisma/migrations/migration_lock.toml
new file mode 100644
index 00000000000..fbffa92c2bb
--- /dev/null
+++ b/examples/core-auth-nextjs-email/src/prisma/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit this file manually
+# It should be added in your version-control system (i.e. Git)
+provider = "postgresql"
\ No newline at end of file
diff --git a/examples/core-auth-nextjs-email/src/prisma/schema.prisma b/examples/core-auth-nextjs-email/src/prisma/schema.prisma
new file mode 100644
index 00000000000..9b35bb87717
--- /dev/null
+++ b/examples/core-auth-nextjs-email/src/prisma/schema.prisma
@@ -0,0 +1,60 @@
+datasource db {
+ provider = "postgresql"
+ url = env("DATABASE_URL")
+}
+
+generator client {
+ provider = "prisma-client-js"
+}
+
+model User {
+ id String @id @default(cuid())
+ name String?
+ email String @unique
+ emailVerified DateTime?
+ image String?
+ accounts Account[]
+ sessions Session[]
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
+model Account {
+ userId String
+ type String
+ provider String
+ providerAccountId String
+ refresh_token String?
+ access_token String?
+ expires_at Int?
+ token_type String?
+ scope String?
+ id_token String?
+ session_state String?
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@id([provider, providerAccountId])
+}
+
+model Session {
+ sessionToken String @unique
+ userId String
+ expires DateTime
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+}
+
+model VerificationToken {
+ identifier String
+ token String
+ expires DateTime
+
+ @@id([identifier, token])
+}
\ No newline at end of file
diff --git a/examples/core-auth-nextjs-email/tsconfig.json b/examples/core-auth-nextjs-email/tsconfig.json
new file mode 100644
index 00000000000..bb5584ed1a4
--- /dev/null
+++ b/examples/core-auth-nextjs-email/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "lib": ["dom", "dom.iterable", "esnext"],
+ "allowJs": true,
+ "skipLibCheck": true,
+ "strict": true,
+ "noEmit": true,
+ "esModuleInterop": true,
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "jsx": "preserve",
+ "incremental": true,
+ "plugins": [
+ {
+ "name": "next"
+ }
+ ]
+ },
+ "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "exclude": ["node_modules"]
+}
diff --git a/examples/core-auth-nextjs-pages/src/auth.ts b/examples/core-auth-nextjs-pages/src/auth.ts
index a377904d3e0..5d3c3452b05 100644
--- a/examples/core-auth-nextjs-pages/src/auth.ts
+++ b/examples/core-auth-nextjs-pages/src/auth.ts
@@ -26,23 +26,11 @@ const providers: Provider[] = [
}),
];
-const missingVars: string[] = [];
-
if (!process.env.GITHUB_CLIENT_ID) {
- missingVars.push('GITHUB_CLIENT_ID');
+ console.warn('Missing environment variable "GITHUB_CLIENT_ID"');
}
if (!process.env.GITHUB_CLIENT_SECRET) {
- missingVars.push('GITHUB_CLIENT_SECRET');
-}
-
-if (missingVars.length > 0) {
- const message = `Authentication is configured but the following environment variables are missing: ${missingVars.join(', ')}`;
-
- if (process.env.NODE_ENV === 'production') {
- throw new Error(message);
- } else {
- console.warn(message);
- }
+ console.warn('Missing environment variable "GITHUB_CLIENT_SECRET"');
}
export const providerMap = providers.map((provider) => {
diff --git a/examples/core-auth-nextjs/src/auth.ts b/examples/core-auth-nextjs/src/auth.ts
index 74dd44ac883..c9874f0a52e 100644
--- a/examples/core-auth-nextjs/src/auth.ts
+++ b/examples/core-auth-nextjs/src/auth.ts
@@ -26,26 +26,11 @@ const providers: Provider[] = [
}),
];
-const missingVars: string[] = [];
-
if (!process.env.GITHUB_CLIENT_ID) {
- missingVars.push('GITHUB_CLIENT_ID');
+ console.warn('Missing environment variable "GITHUB_CLIENT_ID"');
}
if (!process.env.GITHUB_CLIENT_SECRET) {
- missingVars.push('GITHUB_CLIENT_SECRET');
-}
-
-if (missingVars.length > 0) {
- const baseMessage =
- 'Authentication is configured but the following environment variables are missing:';
-
- if (process.env.NODE_ENV === 'production') {
- throw new Error(`error - ${baseMessage} ${missingVars.join(', ')}`);
- } else {
- console.warn(
- `\u001b[33mwarn\u001b[0m - ${baseMessage} \u001b[31m${missingVars.join(', ')}\u001b[0m`,
- );
- }
+ console.warn('Missing environment variable "GITHUB_CLIENT_SECRET"');
}
export const providerMap = providers.map((provider) => {
diff --git a/packages/create-toolpad-app/src/templates/auth/auth.ts b/packages/create-toolpad-app/src/templates/auth/auth.ts
index 89678eaf477..668f65f5eaf 100644
--- a/packages/create-toolpad-app/src/templates/auth/auth.ts
+++ b/packages/create-toolpad-app/src/templates/auth/auth.ts
@@ -23,40 +23,40 @@ const CredentialsProviderTemplate = `Credentials({
const oAuthProviderTemplate = (provider: SupportedAuthProvider) => `
${kebabToPascal(provider)}({
clientId: process.env.${kebabToConstant(provider)}_CLIENT_ID,
- clientSecret: process.env.${kebabToConstant(provider)}_CLIENT_SECRET,${requiresIssuer(provider) ? `\nissuer: process.env.${kebabToConstant(provider)}_ISSUER,\n` : ''}${requiresTenantId(provider) ? `tenantId: process.env.${kebabToConstant(provider)}_TENANT_ID,` : ''}
+ clientSecret: process.env.${kebabToConstant(provider)}_CLIENT_SECRET,${requiresIssuer(provider) ? `\n\t\tissuer: process.env.${kebabToConstant(provider)}_ISSUER,` : ''}${requiresTenantId(provider) ? `\n\t\ttenantId: process.env.${kebabToConstant(provider)}_TENANT_ID,` : ''}
}),`;
-const checkEnvironmentVariables = (
- providers: SupportedAuthProvider[] | undefined,
-) => `const missingVars: string[] = [];
-
-const isMissing = (name: string, envVar: string | undefined) => {
- if (!envVar) {
- missingVars.push(name);
- }
-};
-
-${providers
+const checkEnvironmentVariables = (providers: SupportedAuthProvider[] | undefined) => `${providers
?.filter((p) => p !== 'credentials')
.map(
(provider) =>
- `isMissing('${kebabToConstant(provider)}_CLIENT_ID', process.env.${kebabToConstant(provider)}_CLIENT_ID);\nisMissing('${kebabToConstant(provider)}_CLIENT_SECRET', process.env.${kebabToConstant(provider)}_CLIENT_SECRET)`,
+ `if(!process.env.${kebabToConstant(provider)}_CLIENT_ID) {
+ console.warn('Missing environment variable "${kebabToConstant(provider)}_CLIENT_ID"');
+}
+if(!process.env.${kebabToConstant(provider)}_CLIENT_SECRET) {
+ console.warn('Missing environment variable "${kebabToConstant(provider)}_CLIENT_SECRET"');
+}${
+ requiresTenantId(provider)
+ ? `
+if(!process.env.${kebabToConstant(provider)}_TENANT_ID) {
+ console.warn('Missing environment variable "${kebabToConstant(provider)}_TENANT_ID"');
+}`
+ : ''
+ }${
+ requiresIssuer(provider)
+ ? `
+if(!process.env.${kebabToConstant(provider)}_ISSUER) {
+ console.warn('Missing environment variable "${kebabToConstant(provider)}_ISSUER"');
+}`
+ : ''
+ }`,
)
.join('\n')}
-
-if (missingVars.length > 0) {
- const baseMessage = 'Authentication is configured but the following environment variables are missing:';
-
- if (process.env.NODE_ENV === 'production') {
- console.warn(\`warn: \${baseMessage} \${missingVars.join(', ')}\`);
- } else {
- console.warn(\`\\u001b[33mwarn:\\u001b[0m \${baseMessage} \\u001b[31m\${missingVars.join(', ')}\\u001b[0m\`);
- }
-}`;
+`;
const auth: Template = (options) => {
const providers = options.authProviders;
- return `import NextAuth from 'next-auth';\nimport { AuthProvider, SupportedAuthProvider } from '@toolpad/core/SignInPage';\n${providers
+ return `import NextAuth from 'next-auth';\n${providers
?.map(
(provider) =>
`import ${kebabToPascal(provider)} from 'next-auth/providers/${provider.toLowerCase()}';`,
@@ -79,7 +79,7 @@ ${checkEnvironmentVariables(providers)}
export const providerMap = providers.map((provider) => {
if (typeof provider === 'function') {
const providerData = provider();
- return { id: providerData.id, name: providerData.name };
+ return { id: providerData.id, name: providerData.name };
}
return { id: provider.id, name: provider.name };
});
diff --git a/packages/toolpad-core/src/SignInPage/SignInPage.test.tsx b/packages/toolpad-core/src/SignInPage/SignInPage.test.tsx
index 0d1c82ee7b6..e96ccd03421 100644
--- a/packages/toolpad-core/src/SignInPage/SignInPage.test.tsx
+++ b/packages/toolpad-core/src/SignInPage/SignInPage.test.tsx
@@ -46,6 +46,21 @@ describe('SignInPage', () => {
expect(signIn.mock.calls[0][1].get('password')).toBe('thepassword');
});
+ test('renders nodemailer provider', async () => {
+ const signIn = vi.fn();
+ render();
+
+ const emailField = screen.getByRole('textbox', { name: 'Email Address' });
+ const signInButton = screen.getByRole('button', { name: 'Sign in with Email' });
+
+ await userEvent.type(emailField, 'john@example.com');
+ await userEvent.click(signInButton);
+
+ expect(signIn).toHaveBeenCalled();
+ expect(signIn.mock.calls[0][0]).toHaveProperty('id', 'nodemailer');
+ expect(signIn.mock.calls[0][1].get('email')).toBe('john@example.com');
+ });
+
test('renders passkey sign-in option when available', async () => {
const signIn = vi.fn();
diff --git a/packages/toolpad-core/src/SignInPage/SignInPage.tsx b/packages/toolpad-core/src/SignInPage/SignInPage.tsx
index c30ef91955b..99661325bb4 100644
--- a/packages/toolpad-core/src/SignInPage/SignInPage.tsx
+++ b/packages/toolpad-core/src/SignInPage/SignInPage.tsx
@@ -62,7 +62,11 @@ type SupportedOAuthProvider =
| 'fusionauth'
| 'microsoft-entra-id';
-export type SupportedAuthProvider = SupportedOAuthProvider | 'credentials' | 'passkey';
+export type SupportedAuthProvider =
+ | SupportedOAuthProvider
+ | 'credentials'
+ | 'passkey'
+ | 'nodemailer';
const IconProviderMap = new Map([
['github', ],
@@ -113,10 +117,17 @@ export interface AuthResponse {
*/
error?: string;
/**
- * The type of error that occurred.
+ * The type of error if the sign-in failed.
* @default ''
*/
type?: string;
+ /**
+ * The success notification if the sign-in was successful.
+ * @default ''
+ * Only used for magic link sign-in.
+ * @example 'Check your email for a magic link.'
+ */
+ success?: string;
}
export interface SignInPageSlots {
@@ -206,14 +217,17 @@ function SignInPage(props: SignInPageProps) {
const router = React.useContext(RouterContext);
const passkeyProvider = providers?.find((provider) => provider.id === 'passkey');
const credentialsProvider = providers?.find((provider) => provider.id === 'credentials');
- const [{ loading, selectedProviderId, error }, setFormStatus] = React.useState<{
+ const emailProvider = providers?.find((provider) => provider.id === 'nodemailer');
+ const [{ loading, selectedProviderId, error, success }, setFormStatus] = React.useState<{
loading: boolean;
selectedProviderId?: SupportedAuthProvider;
error?: string;
+ success?: string;
}>({
selectedProviderId: undefined,
loading: false,
error: '',
+ success: '',
});
const callbackUrl = router?.searchParams.get('callbackUrl') ?? '/';
@@ -242,13 +256,20 @@ function SignInPage(props: SignInPageProps) {
Welcome, please sign in to continue
-
+
- {error && selectedProviderId !== 'credentials' && selectedProviderId !== 'passkey' ? (
+ {error &&
+ selectedProviderId !== 'credentials' &&
+ selectedProviderId !== 'passkey' &&
+ selectedProviderId !== 'nodemailer' ? (
{error}
) : null}
{Object.values(providers ?? {}).map((provider) => {
- if (provider.id === 'credentials' || provider.id === 'passkey') {
+ if (
+ provider.id === 'credentials' ||
+ provider.id === 'passkey' ||
+ provider.id === 'nodemailer'
+ ) {
return null;
}
return (
@@ -498,6 +519,89 @@ function SignInPage(props: SignInPageProps) {
) : null}
+
+ {emailProvider ? (
+
+ {singleProvider ? null : or}
+ {error && selectedProviderId === 'nodemailer' ? (
+
+ {error}
+
+ ) : null}
+ {success && selectedProviderId === 'nodemailer' ? (
+
+ {success}
+
+ ) : null}
+ {
+ event.preventDefault();
+ setFormStatus({
+ error: '',
+ selectedProviderId: emailProvider.id,
+ loading: true,
+ });
+ const formData = new FormData(event.currentTarget);
+ const emailResponse = await signIn?.(emailProvider, formData, callbackUrl);
+ setFormStatus((prev) => ({
+ ...prev,
+ loading: false,
+ error: emailResponse?.error,
+ success: emailResponse?.success,
+ }));
+ }}
+ >
+
+ {slots?.submitButton ? (
+
+ ) : (
+
+ Sign in with {emailProvider.name || 'Email'}
+
+ )}
+
+
+ ) : null}
@@ -531,6 +635,7 @@ SignInPage.propTypes /* remove-proptypes */ = {
'line',
'linkedin',
'microsoft-entra-id',
+ 'nodemailer',
'okta',
'passkey',
'slack',
diff --git a/playground/nextjs/src/auth.ts b/playground/nextjs/src/auth.ts
index 323ac97b646..d801fb81b70 100644
--- a/playground/nextjs/src/auth.ts
+++ b/playground/nextjs/src/auth.ts
@@ -62,6 +62,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
pages: {
signIn: '/auth/signin',
},
+ debug: true,
callbacks: {
authorized({ auth: session, request: { nextUrl } }) {
const isLoggedIn = !!session?.user;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 03aff3c5020..43730c26db3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -195,7 +195,7 @@ importers:
version: 7.37.1(eslint@8.57.1)
eslint-plugin-react-compiler:
specifier: latest
- version: 19.0.0-beta-9ee70a1-20241017(eslint@8.57.1)
+ version: 19.0.0-beta-8a03594-20241020(eslint@8.57.1)
eslint-plugin-react-hooks:
specifier: 4.6.2
version: 4.6.2(eslint@8.57.1)
@@ -683,7 +683,7 @@ importers:
dependencies:
'@auth/core':
specifier: 0.37.0
- version: 0.37.0(@simplewebauthn/browser@9.0.1)(@simplewebauthn/server@9.0.3(encoding@0.1.13))
+ version: 0.37.0(@simplewebauthn/browser@9.0.1)(@simplewebauthn/server@9.0.3(encoding@0.1.13))(nodemailer@6.9.15)
'@emotion/cache':
specifier: 11.13.1
version: 11.13.1
@@ -1099,7 +1099,7 @@ importers:
dependencies:
'@auth/core':
specifier: 0.37.0
- version: 0.37.0(@simplewebauthn/browser@9.0.1)(@simplewebauthn/server@9.0.3(encoding@0.1.13))
+ version: 0.37.0(@simplewebauthn/browser@9.0.1)(@simplewebauthn/server@9.0.3(encoding@0.1.13))(nodemailer@6.9.15)
'@mui/material':
specifier: 6.1.4
version: 6.1.4(@emotion/react@11.13.3(@types/react@18.3.11)(react@18.3.1))(@emotion/styled@11.13.0(@emotion/react@11.13.3(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react@18.3.1))(@types/react@18.3.11)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -1239,7 +1239,7 @@ importers:
version: 14.2.15(@babel/core@7.25.8)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-auth:
specifier: 5.0.0-beta.22
- version: 5.0.0-beta.22(@simplewebauthn/browser@9.0.1)(@simplewebauthn/server@9.0.3(encoding@0.1.13))(next@14.2.15(@babel/core@7.25.8)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
+ version: 5.0.0-beta.22(@simplewebauthn/browser@9.0.1)(@simplewebauthn/server@9.0.3(encoding@0.1.13))(next@14.2.15(@babel/core@7.25.8)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@6.9.15)(react@18.3.1)
react:
specifier: 18.3.1
version: 18.3.1
@@ -1281,7 +1281,7 @@ importers:
version: 14.2.15(@babel/core@7.25.8)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
next-auth:
specifier: 5.0.0-beta.22
- version: 5.0.0-beta.22(@simplewebauthn/browser@9.0.1)(@simplewebauthn/server@9.0.3(encoding@0.1.13))(next@14.2.15(@babel/core@7.25.8)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
+ version: 5.0.0-beta.22(@simplewebauthn/browser@9.0.1)(@simplewebauthn/server@9.0.3(encoding@0.1.13))(next@14.2.15(@babel/core@7.25.8)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@6.9.15)(react@18.3.1)
react:
specifier: 18.3.1
version: 18.3.1
@@ -2052,7 +2052,7 @@ packages:
resolution: {integrity: sha512-GjV0/mUEEXpi1U5ZgDprMRRgajGMRW3G5FjMr5KLKD8nT2fTG8+h/klV3+6Dm5739QE+K5+2e91qFKAYI3pmRg==}
engines: {node: '>=6.9.0'}
peerDependencies:
- '@babel/core': ^7.25.8
+ '@babel/core': ^7.0.0-0
'@babel/preset-typescript@7.25.7':
resolution: {integrity: sha512-rkkpaXJZOFN45Fb+Gki0c+KMIglk4+zZXOoMJuyEK8y8Kkc8Jd3BDmP7qPsz0zQMJj+UD7EprF+AqAXcILnexw==}
@@ -2122,7 +2122,7 @@ packages:
'@docsearch/react@3.6.2':
resolution: {integrity: sha512-rtZce46OOkVflCQH71IdbXSFK+S8iJZlUF56XBW5rIgx/eG5qoomC7Ag3anZson1bBac/JFQn7XOBfved/IMRA==}
peerDependencies:
- '@types/react': ^18.3.11
+ '@types/react': '>= 16.8.0 < 19.0.0'
react: '>= 16.8.0 < 19.0.0'
react-dom: '>= 16.8.0 < 19.0.0'
search-insights: '>= 1 < 3'
@@ -2944,7 +2944,7 @@ packages:
resolution: {integrity: sha512-LQZ2907rPMut/2Lq6qSnyP+nqOHLO3buMv91m7SdLpqp/lXU5+8vUXcf5oOwTNis6hfSvYGSQJ493Q00OzxDmQ==}
engines: {node: '>=14.0.0'}
peerDependencies:
- '@types/react': ^18.3.11
+ '@types/react': ^17.0.0 || ^18.0.0
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0
peerDependenciesMeta:
@@ -6058,8 +6058,8 @@ packages:
peerDependencies:
eslint: '>=7.0.0'
- eslint-plugin-react-compiler@19.0.0-beta-9ee70a1-20241017:
- resolution: {integrity: sha512-GdJHMa9Wqfc/JPiv4WW5JjQsuSISdBo7oM/6IjRO8uxaZncDrKK/RyFqbPvgEiNFzDcX8ZZvR8dgfSGvxh2Qpw==}
+ eslint-plugin-react-compiler@19.0.0-beta-8a03594-20241020:
+ resolution: {integrity: sha512-bYg1COih1s3r14IV/AKdQs/SN7CQmNI0ZaMtPdgZ6gp1S1Q/KGP9P43w7R6dHJ4wYpuMBvekNJHQdVu+x6UM+A==}
engines: {node: ^14.17.0 || ^16.0.0 || >= 18.0.0}
peerDependencies:
eslint: '>=7'
@@ -8051,6 +8051,10 @@ packages:
node-releases@2.0.18:
resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==}
+ nodemailer@6.9.15:
+ resolution: {integrity: sha512-AHf04ySLC6CIfuRtRiEYtGEXgRfa6INgWGluDhnxTZhHSKvrBu7lc1VVchQ0d8nPc4cFaZoPq8vkyNoZr0TpGQ==}
+ engines: {node: '>=6.0.0'}
+
nopt@7.2.1:
resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==}
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
@@ -10600,7 +10604,7 @@ snapshots:
'@argos-ci/util@2.1.1': {}
- '@auth/core@0.35.3(@simplewebauthn/browser@9.0.1)(@simplewebauthn/server@9.0.3(encoding@0.1.13))':
+ '@auth/core@0.35.3(@simplewebauthn/browser@9.0.1)(@simplewebauthn/server@9.0.3(encoding@0.1.13))(nodemailer@6.9.15)':
dependencies:
'@panva/hkdf': 1.2.1
'@types/cookie': 0.6.0
@@ -10612,8 +10616,9 @@ snapshots:
optionalDependencies:
'@simplewebauthn/browser': 9.0.1
'@simplewebauthn/server': 9.0.3(encoding@0.1.13)
+ nodemailer: 6.9.15
- '@auth/core@0.37.0(@simplewebauthn/browser@9.0.1)(@simplewebauthn/server@9.0.3(encoding@0.1.13))':
+ '@auth/core@0.37.0(@simplewebauthn/browser@9.0.1)(@simplewebauthn/server@9.0.3(encoding@0.1.13))(nodemailer@6.9.15)':
dependencies:
'@panva/hkdf': 1.2.1
'@types/cookie': 0.6.0
@@ -10625,6 +10630,7 @@ snapshots:
optionalDependencies:
'@simplewebauthn/browser': 9.0.1
'@simplewebauthn/server': 9.0.3(encoding@0.1.13)
+ nodemailer: 6.9.15
'@babel/cli@7.25.7(@babel/core@7.25.8)':
dependencies:
@@ -15931,7 +15937,7 @@ snapshots:
globals: 13.24.0
rambda: 7.5.0
- eslint-plugin-react-compiler@19.0.0-beta-9ee70a1-20241017(eslint@8.57.1):
+ eslint-plugin-react-compiler@19.0.0-beta-8a03594-20241020(eslint@8.57.1):
dependencies:
'@babel/core': 7.25.8
'@babel/parser': 7.25.8
@@ -18254,14 +18260,15 @@ snapshots:
nested-error-stacks@2.1.1: {}
- next-auth@5.0.0-beta.22(@simplewebauthn/browser@9.0.1)(@simplewebauthn/server@9.0.3(encoding@0.1.13))(next@14.2.15(@babel/core@7.25.8)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1):
+ next-auth@5.0.0-beta.22(@simplewebauthn/browser@9.0.1)(@simplewebauthn/server@9.0.3(encoding@0.1.13))(next@14.2.15(@babel/core@7.25.8)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@6.9.15)(react@18.3.1):
dependencies:
- '@auth/core': 0.35.3(@simplewebauthn/browser@9.0.1)(@simplewebauthn/server@9.0.3(encoding@0.1.13))
+ '@auth/core': 0.35.3(@simplewebauthn/browser@9.0.1)(@simplewebauthn/server@9.0.3(encoding@0.1.13))(nodemailer@6.9.15)
next: 14.2.15(@babel/core@7.25.8)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
optionalDependencies:
'@simplewebauthn/browser': 9.0.1
'@simplewebauthn/server': 9.0.3(encoding@0.1.13)
+ nodemailer: 6.9.15
next-router-mock@0.9.13(next@14.2.15(@babel/core@7.25.8)(@playwright/test@1.47.2)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1):
dependencies:
@@ -18349,6 +18356,9 @@ snapshots:
node-releases@2.0.18: {}
+ nodemailer@6.9.15:
+ optional: true
+
nopt@7.2.1:
dependencies:
abbrev: 2.0.0