diff --git a/apps/dev/pages/api/auth/[...nextauth].ts b/apps/dev/pages/api/auth/[...nextauth].ts index 041ac8450c..45c5f8f8dc 100644 --- a/apps/dev/pages/api/auth/[...nextauth].ts +++ b/apps/dev/pages/api/auth/[...nextauth].ts @@ -46,7 +46,10 @@ import BoxyHQSAMLProvider from "next-auth/providers/boxyhq-saml" // }) // const adapter = FaunaAdapter(client) export const authOptions: NextAuthOptions = { - // adapter, + // adapter: { + // getUserByEmail: (email) => ({ id: "1", email, emailVerified: null }), + // createVerificationToken: (token) => token, + // } as any, providers: [ // E-mail // Start fake e-mail server with `npm run start:email` diff --git a/docs/docs/configuration/options.md b/docs/docs/configuration/options.md index 02b0c2ea05..870472e0f6 100644 --- a/docs/docs/configuration/options.md +++ b/docs/docs/configuration/options.md @@ -366,11 +366,14 @@ Changes the color scheme theme of [pages](/configuration/pages) as well as allow In addition, you can define a logo URL in `theme.logo` which will be rendered above the main card in the default signin/signout/error/verify-request pages, as well as a `theme.brandColor` which will affect the accent color of these pages. +The sign-in button's background color will match the `brandColor` and defaults to `"#346df1"`. The text color is `#fff` by default, but if your brand color gives a weak contrast, correct it with the `buttonText` color option. + ```js theme: { colorScheme: "auto", // "auto" | "dark" | "light" brandColor: "", // Hex color code - logo: "" // Absolute URL to image + logo: "", // Absolute URL to image + buttonText: "" // Hex color code } ``` diff --git a/docs/docs/providers/email.md b/docs/docs/providers/email.md index 0551118ab4..6ddefe655f 100644 --- a/docs/docs/providers/email.md +++ b/docs/docs/providers/email.md @@ -124,67 +124,74 @@ providers: [ The following code shows the complete source for the built-in `sendVerificationRequest()` method: ```js -import nodemailer from "nodemailer" +import { createTransport } from "nodemailer" -async function sendVerificationRequest({ - identifier: email, - url, - provider: { server, from }, -}) { +async function sendVerificationRequest(params) { + const { identifier, url, provider, theme } = params const { host } = new URL(url) - const transport = nodemailer.createTransport(server) - await transport.sendMail({ - to: email, - from, + // NOTE: You are not required to use `nodemailer`, use whatever you want. + const transport = createTransport(provider.server) + const result = await transport.sendMail({ + to: identifier, + from: provider.from, subject: `Sign in to ${host}`, text: text({ url, host }), - html: html({ url, host, email }), + html: html({ url, host, theme }), }) + const failed = result.rejected.concat(result.pending).filter(Boolean) + if (failed.length) { + throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`) + } } -// Email HTML body -function html({ url, host, email }: Record<"url" | "host" | "email", string>) { - // Insert invisible space into domains and email address to prevent both the - // email address and the domain 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 their email address to sign in. - const escapedEmail = `${email.replace(/\./g, "​.")}` - const escapedHost = `${host.replace(/\./g, "​.")}` - - // Some simple styling options - const backgroundColor = "#f9f9f9" - const textColor = "#444444" - const mainBackgroundColor = "#ffffff" - const buttonBackgroundColor = "#346df1" - const buttonBorderColor = "#346df1" - const buttonTextColor = "#ffffff" +/** + * 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 html(params: { url: string; host: string; theme: Theme }) { + const { url, host, theme } = params + + const escapedHost = host.replace(/\./g, "​.") + + const brandColor = theme.brandColor || "#346df1" + const color = { + background: "#f9f9f9", + text: "#444", + mainBackground: "#fff", + buttonBackground: brandColor, + buttonBorder: brandColor, + buttonText: theme.buttonText || "#fff", + } return ` - - + +
- - -
- ${escapedHost} -
- - - - @@ -193,8 +200,8 @@ function html({ url, host, email }: Record<"url" | "host" | "email", string>) { ` } -// Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) -function text({ url, host }: Record<"url" | "host", string>) { +/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */ +function text({ url, host }: { url: string; host: string }) { return `Sign in to ${host}\n${url}\n\n` } ``` diff --git a/packages/next-auth/src/core/init.ts b/packages/next-auth/src/core/init.ts index 5d2e489529..38a38acc53 100644 --- a/packages/next-auth/src/core/init.ts +++ b/packages/next-auth/src/core/init.ts @@ -62,6 +62,7 @@ export async function init({ colorScheme: "auto", logo: "", brandColor: "", + buttonText: "", }, // Custom options override defaults ...userOptions, diff --git a/packages/next-auth/src/core/lib/email/signin.ts b/packages/next-auth/src/core/lib/email/signin.ts index ae4fa831ed..cd58f9262e 100644 --- a/packages/next-auth/src/core/lib/email/signin.ts +++ b/packages/next-auth/src/core/lib/email/signin.ts @@ -10,7 +10,7 @@ export default async function email( identifier: string, options: InternalOptions<"email"> ) { - const { url, adapter, provider, logger, callbackUrl } = options + const { url, adapter, provider, logger, callbackUrl, theme } = options // Generate token const token = @@ -42,6 +42,7 @@ export default async function email( expires, url: _url, provider, + theme, }) } catch (error) { logger.error("SEND_VERIFICATION_EMAIL_ERROR", { diff --git a/packages/next-auth/src/core/routes/signin.ts b/packages/next-auth/src/core/routes/signin.ts index 0bd48731f7..cd1547015e 100644 --- a/packages/next-auth/src/core/routes/signin.ts +++ b/packages/next-auth/src/core/routes/signin.ts @@ -37,19 +37,10 @@ export default async function signin(params: { * it solves. We treat email addresses as all lower case. If anyone * complains about this we can make strict RFC 2821 compliance an option. */ - let email = body?.email?.toLowerCase() + const email = body?.email?.toLowerCase() if (!email) return { redirect: `${url}/error?error=EmailSignin` } - email = email - .split(",")[0] - .trim() - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'") - // Verified in `assertConfig` // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const { getUserByEmail } = adapter! diff --git a/packages/next-auth/src/core/types.ts b/packages/next-auth/src/core/types.ts index 5f7590b45f..a66800a73b 100644 --- a/packages/next-auth/src/core/types.ts +++ b/packages/next-auth/src/core/types.ts @@ -217,6 +217,7 @@ export interface Theme { colorScheme: "auto" | "dark" | "light" logo?: string brandColor?: string + buttonText?: string } /** diff --git a/packages/next-auth/src/providers/email.ts b/packages/next-auth/src/providers/email.ts index 185fcb67f0..89aca9d5ab 100644 --- a/packages/next-auth/src/providers/email.ts +++ b/packages/next-auth/src/providers/email.ts @@ -3,6 +3,16 @@ import { createTransport } from "nodemailer" import type { CommonProviderOptions } from "." import type { Options as SMTPConnectionOptions } from "nodemailer/lib/smtp-connection" import type { Awaitable } from ".." +import type { Theme } from "../core/types" + +export interface SendVerificationRequestParams { + identifier: string + url: string + expires: Date + provider: EmailConfig + token: string + theme: Theme +} export interface EmailConfig extends CommonProviderOptions { type: "email" @@ -16,13 +26,10 @@ export interface EmailConfig extends CommonProviderOptions { * @default 86400 */ maxAge?: number - sendVerificationRequest: (params: { - identifier: string - url: string - expires: Date - provider: EmailConfig - token: string - }) => Awaitable + /** [Documentation](https://next-auth.js.org/providers/email#customizing-emails) */ + sendVerificationRequest: ( + params: SendVerificationRequestParams + ) => Awaitable /** * By default, we are generating a random verification token. * You can make it predictable or modify it as you like with this method. @@ -56,78 +63,81 @@ export default function Email(options: EmailUserConfig): EmailConfig { type: "email", name: "Email", // Server can be an SMTP connection string or a nodemailer config object - server: { - host: "localhost", - port: 25, - auth: { - user: "", - pass: "", - }, - }, + server: { host: "localhost", port: 25, auth: { user: "", pass: "" } }, from: "NextAuth ", maxAge: 24 * 60 * 60, - async sendVerificationRequest({ - identifier: email, - url, - provider: { server, from }, - }) { + async sendVerificationRequest(params) { + const { identifier, url, provider, theme } = params const { host } = new URL(url) - const transport = createTransport(server) - await transport.sendMail({ - to: email, - from, + const transport = createTransport(provider.server) + const result = await transport.sendMail({ + to: identifier, + from: provider.from, subject: `Sign in to ${host}`, text: text({ url, host }), - html: html({ url, host, email }), + html: html({ url, host, theme }), }) + const failed = result.rejected.concat(result.pending).filter(Boolean) + if (failed.length) { + throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`) + } }, options, } } -// Email HTML body -function html({ url, host, email }: Record<"url" | "host" | "email", string>) { - // Insert invisible space into domains and email address to prevent both the - // email address and the domain 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 their email address to sign in. - const escapedEmail = `${email.replace(/\./g, "​.")}` - const escapedHost = `${host.replace(/\./g, "​.")}` +/** + * 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 html(params: { url: string; host: string; theme: Theme }) { + const { url, host, theme } = params + + const escapedHost = host.replace(/\./g, "​.") + + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const brandColor = theme.brandColor || "#346df1" + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const buttonText = theme.buttonText || "#fff" - // Some simple styling options - const backgroundColor = "#f9f9f9" - const textColor = "#444444" - const mainBackgroundColor = "#ffffff" - const buttonBackgroundColor = "#346df1" - const buttonBorderColor = "#346df1" - const buttonTextColor = "#ffffff" + const color = { + background: "#f9f9f9", + text: "#444", + mainBackground: "#fff", + buttonBackground: brandColor, + buttonBorder: brandColor, + buttonText, + } return ` - -
- Sign in as ${escapedEmail} + + Sign in to ${escapedHost}
- +
Sign inSign + in
+ If you did not request this email you can safely ignore it.
- - - -
- ${escapedHost} -
- + +
- - @@ -136,7 +146,7 @@ function html({ url, host, email }: Record<"url" | "host" | "email", string>) { ` } -// Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) -function text({ url, host }: Record<"url" | "host", string>) { +/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */ +function text({ url, host }: { url: string; host: string }) { return `Sign in to ${host}\n${url}\n\n` } diff --git a/packages/next-auth/tests/email.test.ts b/packages/next-auth/tests/email.test.ts deleted file mode 100644 index 43c3973fb5..0000000000 --- a/packages/next-auth/tests/email.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { createCSRF, handler } from "./lib" -import EmailProvider from "../src/providers/email" - -const originalEmail = "balazs@email.com" - -test.each([ - [originalEmail, `,Click here!`], - [originalEmail, ""], -])("Sanitize email", async (emailOriginal, emailCompromised) => { - const sendEmail = jest.fn() - - const { secret, csrf } = createCSRF() - - const email = { - original: emailOriginal, - compromised: `${emailOriginal}${emailCompromised}`, - } - - const { res } = await handler( - { - providers: [EmailProvider({ sendVerificationRequest: sendEmail })], - adapter: { - getUserByEmail: (email) => ({ id: "1", email, emailVerified: null }), - createVerificationToken: (token) => token, - } as any, - secret, - }, - { - prod: true, - path: "signin/email", - requestInit: { - method: "POST", - body: JSON.stringify({ - email: email.compromised, - csrfToken: csrf.value, - }), - headers: { "Content-Type": "application/json", Cookie: csrf.cookie }, - }, - } - ) - - if (!emailCompromised) { - expect(res.redirect).toBe( - "http://localhost:3000/api/auth/verify-request?provider=email&type=email" - ) - expect(sendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - identifier: email.original, - token: expect.any(String), - }) - ) - } else { - expect(res.redirect).not.toContain("error=EmailSignin") - - const emailTo = sendEmail.mock.calls[0][0].identifier - expect(emailTo).not.toBe(email.compromised) - expect(emailTo).toBe(email.original) - } -})
- Sign in as ${escapedEmail} + + Sign in to ${escapedHost}
- +
Sign inSign + in
+ If you did not request this email you can safely ignore it.