mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
Add email login option
This commit is contained in:
@@ -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=
|
||||
|
||||
Generated
+51
-18
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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),
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Vendored
+6
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user