Add email login option

This commit is contained in:
Justin Maier
2023-01-02 15:59:36 -07:00
parent a42b0aa26b
commit c8a0705feb
9 changed files with 260 additions and 30 deletions
+8
View File
@@ -37,6 +37,14 @@ CF_IMAGES_TOKEN=
CF_ACCOUNT_ID=
NEXT_PUBLIC_IMAGE_LOCATION=
# Email
EMAIL_HOST=
EMAIL_PORT=
EMAIL_SECURE=
EMAIL_USER=
EMAIL_PASS=
EMAIL_FROM=
# Endpoint Protection
JOB_TOKEN=
WEBHOOK_TOKEN=
+51 -18
View File
@@ -61,6 +61,7 @@
"masonic": "^3.7.0",
"next": "12.3.1",
"next-auth": "^4.18.7",
"nodemailer": "^6.8.0",
"query-string": "^7.1.1",
"react": "18.2.0",
"react-blurhash": "^0.2.0",
@@ -85,6 +86,7 @@
"@next/eslint-plugin-next": "^12.1.4",
"@types/lodash": "^4.14.189",
"@types/node": "18.11.0",
"@types/nodemailer": "^6.4.7",
"@types/react": "18.0.14",
"@types/react-dom": "18.0.5",
"@types/request-ip": "^0.0.37",
@@ -3474,6 +3476,15 @@
"integrity": "sha512-IOXCvVRToe7e0ny7HpT/X9Rb2RYtElG1a+VshjwT00HxrM2dWBApHQoqsI6WiY7Q03vdf2bCrIGzVrkF/5t10w==",
"dev": true
},
"node_modules/@types/nodemailer": {
"version": "6.4.7",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.7.tgz",
"integrity": "sha512-f5qCBGAn/f0qtRcd4SEn88c8Fp3Swct1731X4ryPKqS61/A3LmmzN8zaEz7hneJvpjFbUUgY7lru/B/7ODTazg==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@@ -4554,9 +4565,9 @@
}
},
"node_modules/decode-uri-component": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
"integrity": "sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og==",
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
"integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
"engines": {
"node": ">=0.10"
}
@@ -6820,9 +6831,9 @@
"dev": true
},
"node_modules/json5": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
"integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA==",
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"bin": {
"json5": "lib/cli.js"
},
@@ -7697,6 +7708,14 @@
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz",
"integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg=="
},
"node_modules/nodemailer": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.8.0.tgz",
"integrity": "sha512-EjYvSmHzekz6VNkNd12aUqAco+bOkRe3Of5jVhltqKhEsjw/y0PYPJfp83+s9Wzh1dspYAkUW/YNQ350NATbSQ==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
@@ -9509,9 +9528,9 @@
}
},
"node_modules/tsconfig-paths/node_modules/json5": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"dev": true,
"dependencies": {
"minimist": "^1.2.0"
@@ -12486,6 +12505,15 @@
"integrity": "sha512-IOXCvVRToe7e0ny7HpT/X9Rb2RYtElG1a+VshjwT00HxrM2dWBApHQoqsI6WiY7Q03vdf2bCrIGzVrkF/5t10w==",
"dev": true
},
"@types/nodemailer": {
"version": "6.4.7",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.7.tgz",
"integrity": "sha512-f5qCBGAn/f0qtRcd4SEn88c8Fp3Swct1731X4ryPKqS61/A3LmmzN8zaEz7hneJvpjFbUUgY7lru/B/7ODTazg==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/parse-json": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz",
@@ -13245,9 +13273,9 @@
}
},
"decode-uri-component": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz",
"integrity": "sha512-hjf+xovcEn31w/EUYdTXQh/8smFL/dzYjohQGEIgjyNavaJfBY2p5F527Bo1VPATxv0VYTUC2bOcXvqFwk78Og=="
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
"integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="
},
"decompress-response": {
"version": "6.0.0",
@@ -14880,9 +14908,9 @@
"dev": true
},
"json5": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz",
"integrity": "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA=="
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="
},
"jsx-ast-utils": {
"version": "3.3.3",
@@ -15418,6 +15446,11 @@
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.6.tgz",
"integrity": "sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg=="
},
"nodemailer": {
"version": "6.8.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.8.0.tgz",
"integrity": "sha512-EjYvSmHzekz6VNkNd12aUqAco+bOkRe3Of5jVhltqKhEsjw/y0PYPJfp83+s9Wzh1dspYAkUW/YNQ350NATbSQ=="
},
"oauth": {
"version": "0.9.15",
"resolved": "https://registry.npmjs.org/oauth/-/oauth-0.9.15.tgz",
@@ -16725,9 +16758,9 @@
},
"dependencies": {
"json5": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
"dev": true,
"requires": {
"minimist": "^1.2.0"
+2
View File
@@ -78,6 +78,7 @@
"masonic": "^3.7.0",
"next": "12.3.1",
"next-auth": "^4.18.7",
"nodemailer": "^6.8.0",
"query-string": "^7.1.1",
"react": "18.2.0",
"react-blurhash": "^0.2.0",
@@ -102,6 +103,7 @@
"@next/eslint-plugin-next": "^12.1.4",
"@types/lodash": "^4.14.189",
"@types/node": "18.11.0",
"@types/nodemailer": "^6.4.7",
"@types/react": "18.0.14",
"@types/react-dom": "18.0.5",
"@types/request-ip": "^0.0.37",
+52
View File
@@ -0,0 +1,52 @@
import { Alert, Group, Stack, ThemeIcon, Text } from '@mantine/core';
import { IconMail } from '@tabler/icons';
import { signIn } from 'next-auth/react';
import { useState } from 'react';
import { z } from 'zod';
import { SocialButton } from '~/components/Social/SocialButton';
import { Form, InputText, useForm } from '~/libs/form';
const schema = z.object({ email: z.string().email() });
export const EmailLogin = () => {
const [submitted, setSubmitted] = useState(false);
const form = useForm({ schema });
const handleEmailLogin = ({ email }: z.infer<typeof schema>) => {
setSubmitted(true);
signIn('email', { email, redirect: false });
};
if (submitted)
return (
<Alert pl={15}>
<Group noWrap>
<ThemeIcon size="lg">
<IconMail size={20} />
</ThemeIcon>
<Stack spacing={0}>
<Text
size="md"
sx={{ lineHeight: 1.1 }}
>{`Check your email for a special login link`}</Text>
<Text size="xs" color="dimmed">
Be sure to check your spam...
</Text>
</Stack>
</Group>
</Alert>
);
return (
<Form form={form} onSubmit={handleEmailLogin}>
<Stack>
<InputText
name="email"
type="email"
label="Email"
placeholder="coolperson@email.com"
withAsterisk
/>
<SocialButton provider="email" type="submit" />
</Stack>
</Form>
);
};
+24
View File
@@ -5,6 +5,7 @@ import {
IconBrandGithub,
IconBrandGoogle,
IconBrandReddit,
IconMail,
} from '@tabler/icons';
import { BuiltInProviderType } from 'next-auth/providers';
@@ -40,11 +41,17 @@ export const socialItems: SocialProps = {
Icon: IconBrandReddit,
Button: RedditButton,
},
email: {
label: 'Email',
Icon: IconMail,
Button: EmailButton,
},
};
const discordColor = '#5865F2';
const googleColor = '#4285F4';
const redditColor = '#FF5700';
const emailColor = '#666';
export function DiscordButton(props: ButtonProps) {
return (
@@ -118,3 +125,20 @@ export function RedditButton(props: ButtonProps) {
/>
);
}
export function EmailButton(props: ButtonProps) {
return (
<Button
{...props}
sx={(theme) => ({
backgroundColor: emailColor,
'&:hover': {
backgroundColor:
theme.colorScheme === 'dark'
? theme.fn.lighten(emailColor, 0.1)
: theme.fn.darken(emailColor, 0.05),
},
})}
/>
);
}
+6
View File
@@ -24,6 +24,12 @@ export const serverSchema = z.object({
GOOGLE_CLIENT_SECRET: z.string(),
REDDIT_CLIENT_ID: z.string(),
REDDIT_CLIENT_SECRET: z.string(),
EMAIL_HOST: z.string(),
EMAIL_PORT: z.preprocess((x) => parseInt(String(x)), z.number()),
EMAIL_SECURE: z.preprocess((val) => val === true || val === 'true', z.boolean()),
EMAIL_USER: z.string(),
EMAIL_PASS: z.string(),
EMAIL_FROM: z.string(),
S3_UPLOAD_KEY: z.string(),
S3_ORIGINS: z.preprocess((value) => {
const str = String(value);
+15
View File
@@ -6,11 +6,13 @@ import DiscordProvider from 'next-auth/providers/discord';
import GithubProvider from 'next-auth/providers/github';
import GoogleProvider from 'next-auth/providers/google';
import RedditProvider from 'next-auth/providers/reddit';
import EmailProvider from 'next-auth/providers/email';
// Prisma adapter for NextAuth, optional and can be removed
import { env } from '~/env/server.mjs';
import { prisma } from '~/server/db/client';
import { getRandomInt } from '~/utils/number-helpers';
import { sendVerificationRequest } from '~/server/auth/verificationEmail';
const setUserName = async (email: string) => {
try {
@@ -92,6 +94,19 @@ export const createAuthOptions = (req: NextApiRequest): NextAuthOptions => ({
},
},
}),
EmailProvider({
server: {
host: env.EMAIL_HOST,
port: env.EMAIL_PORT,
secure: env.EMAIL_SECURE,
auth: {
user: env.EMAIL_USER,
pass: env.EMAIL_PASS,
},
},
sendVerificationRequest,
from: env.EMAIL_FROM,
}),
CredentialsProvider({
name: 'Credentials',
credentials: {
+16 -12
View File
@@ -1,10 +1,10 @@
import { Container, Paper, Stack, Text, Alert, Group, ThemeIcon } from '@mantine/core';
import { Container, Paper, Stack, Text, Alert, Group, ThemeIcon, Divider } from '@mantine/core';
import { IconExclamationMark } from '@tabler/icons';
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { BuiltInProviderType } from 'next-auth/providers';
import { getCsrfToken, getProviders, signIn } from 'next-auth/react';
import { useRouter } from 'next/router';
import React from 'react';
import { EmailLogin } from '~/components/EmailLogin/EmailLogin';
import { SignInError } from '~/components/SignInError/SignInError';
import { SocialButton } from '~/components/Social/SocialButton';
@@ -41,18 +41,22 @@ export default function Login({
Welcome to Civitai, sign in with
</Text>
<Stack mb="md" mt="md">
<Stack mb={error ? 'md' : undefined} mt="md">
{providers
? Object.values(providers).map((provider) => {
return (
<SocialButton
key={provider.name}
provider={provider.id as BuiltInProviderType}
onClick={() => signIn(provider.id, { callbackUrl: returnUrl })}
/>
);
})
? Object.values(providers)
.filter((x) => x.id !== 'email')
.map((provider) => {
return (
<SocialButton
key={provider.name}
provider={provider.id as BuiltInProviderType}
onClick={() => signIn(provider.id, { callbackUrl: returnUrl })}
/>
);
})
: null}
<Divider label="Or" labelPosition="center" />
<EmailLogin />
</Stack>
{error && (
<SignInError
+86
View File
@@ -0,0 +1,86 @@
import { Theme } from 'next-auth';
import { SendVerificationRequestParams } from 'next-auth/providers';
import { createTransport } from 'nodemailer';
export async function sendVerificationRequest({
identifier: to,
url,
provider: { server, from },
theme,
}: SendVerificationRequestParams) {
// NOTE: You are not required to use `nodemailer`, use whatever you want.
const transport = createTransport(server);
const result = await transport.sendMail({
to,
from,
subject: `Sign in to Civitai`,
text: text({ url }),
html: html({ url, 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
* 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({ url, theme }: { url: string; theme: Theme }) {
const brandColor = theme.brandColor || '#346df1';
const color = {
background: '#f9f9f9',
text: '#444',
mainBackground: '#fff',
buttonBackground: brandColor,
buttonBorder: brandColor,
buttonText: theme.buttonText || '#fff',
};
return `
<body style="background: ${color.background};">
<table width="100%" border="0" cellspacing="0" cellpadding="0" style="max-width: 600px; margin: auto;">
<tr><td height="20"></td></tr>
<tr><td>
<table width="100%" border="0" cellspacing="20" cellpadding="0" style="background: ${color.mainBackground}; border-radius: 10px;">
<tr>
<td align="center"
style="padding: 10px 0px; font-size: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
Sign in to <strong>Civitai</strong>
</td>
</tr>
<tr>
<td align="center" style="padding: 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;">Sign
in</a></td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center"
style="padding: 0px 0px 10px 0px; font-size: 16px; line-height: 22px; font-family: Helvetica, Arial, sans-serif; color: ${color.text};">
If you did not request this email you can safely ignore it.
</td>
</tr>
</table>
</td></tr>
<tr><td height="20"></td></tr>
</table>
</body>
`;
}
/** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
function text({ url }: { url: string }) {
return `Sign in to Civitai:\n${url}\n\n`;
}