️ (auth) Attempt to fix issue with link openers in corporate setups (#1819)

Also add option to provide type the login code instead of link-only
option Closes #1817
This commit is contained in:
Baptiste Arnaud 2024-10-04 18:26:30 +02:00 committed by GitHub
parent 6ec8f71cac
commit d3a869498e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 565 additions and 1769 deletions

View File

@ -70,9 +70,10 @@
"ky": "1.2.4",
"micro-cors": "0.1.1",
"next": "14.2.13",
"next-auth": "4.22.1",
"@typebot.io/transactional": "workspace:*",
"next-auth": "4.24.8",
"nextjs-cors": "2.1.2",
"nodemailer": "6.9.8",
"nodemailer": "6.9.15",
"nprogress": "0.2.0",
"openai": "4.52.7",
"papaparse": "5.4.1",

View File

@ -5,14 +5,18 @@ import {
Alert,
AlertIcon,
Button,
Flex,
FormControl,
FormLabel,
HStack,
type HTMLChakraProps,
Input,
PinInput,
PinInputField,
SlideFade,
Spinner,
Stack,
Text,
VStack,
} from "@chakra-ui/react";
import { useTranslate } from "@tolgee/react";
import type { BuiltInProviderType } from "next-auth/providers/index";
@ -44,7 +48,7 @@ export const SignInForm = ({
const [isLoadingProviders, setIsLoadingProviders] = useState(true);
const [emailValue, setEmailValue] = useState(defaultEmail ?? "");
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
const [isMagicCodeSent, setIsMagicCodeSent] = useState(false);
const { showToast } = useToast();
const [providers, setProviders] =
@ -84,7 +88,7 @@ export const SignInForm = ({
const handleEmailSubmit = async (e: FormEvent) => {
e.preventDefault();
if (isMagicLinkSent) return;
if (isMagicCodeSent) return;
setAuthLoading(true);
try {
const response = await signIn("email", {
@ -112,7 +116,7 @@ export const SignInForm = ({
},
});
} else {
setIsMagicLinkSent(true);
setIsMagicCodeSent(true);
}
} catch (e) {
showToast({
@ -123,6 +127,18 @@ export const SignInForm = ({
setAuthLoading(false);
};
const checkCodeAndRedirect = async (token: string) => {
const url = new URL(`${window.location.origin}/api/auth/callback/email`);
url.searchParams.set("token", token);
url.searchParams.set("email", emailValue);
const redirectPath = router.query.redirectPath?.toString();
url.searchParams.set(
"callbackUrl",
`${window.location.origin}${redirectPath ?? "/typebots"}`,
);
window.location.assign(url.toString());
};
if (isLoadingProviders) return <Spinner />;
if (hasNoAuthProvider)
return (
@ -138,7 +154,7 @@ export const SignInForm = ({
);
return (
<Stack spacing="4" w="330px">
{!isMagicLinkSent && (
{!isMagicCodeSent && (
<>
<SocialLoginButtons providers={providers} />
{providers?.email && (
@ -159,7 +175,7 @@ export const SignInForm = ({
isLoading={
["loading", "authenticated"].includes(status) || authLoading
}
isDisabled={isMagicLinkSent}
isDisabled={isMagicCodeSent}
>
{t("auth.emailSubmitButton.label")}
</Button>
@ -171,8 +187,8 @@ export const SignInForm = ({
{router.query.error && (
<SignInError error={router.query.error.toString()} />
)}
<SlideFade offsetY="20px" in={isMagicLinkSent} unmountOnExit>
<Flex>
<SlideFade offsetY="20px" in={isMagicCodeSent} unmountOnExit>
<Stack spacing={3}>
<Alert status="success" w="100%">
<HStack>
<AlertIcon />
@ -182,7 +198,20 @@ export const SignInForm = ({
</Stack>
</HStack>
</Alert>
</Flex>
<FormControl as={VStack} spacing={0}>
<FormLabel>Login code:</FormLabel>
<HStack>
<PinInput onComplete={checkCodeAndRedirect}>
<PinInputField />
<PinInputField />
<PinInputField />
<PinInputField />
<PinInputField />
<PinInputField />
</PinInput>
</HStack>
</FormControl>
</Stack>
</SlideFade>
</Stack>
);

View File

@ -1,4 +1,4 @@
import { sendMagicLinkEmail } from "@typebot.io/emails/emails/MagicLinkEmail";
import { sendLoginCodeEmail } from "@typebot.io/transactional/emails/LoginCodeEmail";
type Props = {
identifier: string;
@ -7,9 +7,17 @@ type Props = {
export const sendVerificationRequest = async ({ identifier, url }: Props) => {
try {
await sendMagicLinkEmail({ url, to: identifier });
const code = extractCodeFromUrl(url);
if (!code) throw new Error("Could not extract code from url");
await sendLoginCodeEmail({ url, code, to: identifier });
} catch (err) {
console.error(err);
throw new Error(`Magic link email could not be sent. See error above.`);
}
};
const extractCodeFromUrl = (url: string) => {
const urlParts = url.split("?");
const queryParams = new URLSearchParams(urlParts[1]);
return queryParams.get("token");
};

View File

@ -73,7 +73,12 @@ if (env.NEXT_PUBLIC_SMTP_FROM && !env.SMTP_AUTH_DISABLED)
}
: undefined,
},
maxAge: 5 * 60,
from: env.NEXT_PUBLIC_SMTP_FROM,
generateVerificationToken() {
const code = Math.floor(100000 + Math.random() * 900000); // random 6-digit code
return code.toString();
},
sendVerificationRequest,
}),
);
@ -234,6 +239,8 @@ export const getAuthOptions = ({
});
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
// Ignore email link openers (common in enterprise setups)
if (req.method === "HEAD") return res.status(200).end();
const isMockingSession =
req.method === "GET" &&
req.url === "/api/auth/session" &&

View File

@ -27,7 +27,7 @@
"google-spreadsheet": "4.1.1",
"next": "14.2.13",
"nextjs-cors": "2.1.2",
"nodemailer": "6.9.8",
"nodemailer": "6.9.15",
"openai": "4.52.7",
"react": "18.2.0",
"react-dom": "18.2.0",

BIN
bun.lockb

Binary file not shown.

View File

@ -36,7 +36,7 @@
"ky": "1.2.4",
"libphonenumber-js": "1.10.37",
"node-html-parser": "6.1.5",
"nodemailer": "6.9.8",
"nodemailer": "6.9.15",
"openai": "4.52.7",
"qs": "6.11.2",
"stripe": "17.1.0"

View File

@ -12,7 +12,7 @@
},
"dependencies": {
"@faire/mjml-react": "3.3.0",
"nodemailer": "6.9.8",
"nodemailer": "6.9.15",
"react": "18.2.0",
"@typebot.io/lib": "workspace:*",
"@typebot.io/env": "workspace:*"

View File

@ -0,0 +1,147 @@
import {
Body,
Button,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Section,
Text,
} from "@react-email/components";
import { render } from "@react-email/render";
import { env } from "@typebot.io/env";
import type { SendMailOptions } from "nodemailer";
import type { ComponentProps } from "react";
import { sendEmail } from "../helpers/sendEmail";
interface Props {
url: string;
code: string;
}
export const LoginCodeEmail = ({ url, code }: Props) => (
<Html>
<Head />
<Preview>Your login code for Typebot</Preview>
<Body style={main}>
<Container style={container}>
<Img
src={`${env.NEXTAUTH_URL}/images/logo.png`}
width="32"
height="32"
alt="Typebot's Logo"
style={{
margin: "24px 0",
}}
/>
<Heading style={heading}>Your login code for Typebot</Heading>
<Section style={buttonContainer}>
<Button style={button} href={url}>
Login to Typebot
</Button>
</Section>
<Text style={paragraph}>
This link and code will only be valid for the next 5 minutes. If the
link does not work, you can use the login verification code directly:
</Text>
<code style={codeStyle}>{code}</code>
<Hr style={hr} />
<Link href="https://typebot.io" style={reportLink}>
Typebot
</Link>
</Container>
</Body>
</Html>
);
LoginCodeEmail.PreviewProps = {
url: "https://typebot.io",
code: "654778",
} as Props;
export default LoginCodeEmail;
const logo = {
borderRadius: 21,
width: 42,
height: 42,
};
const main = {
backgroundColor: "#ffffff",
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif',
};
const container = {
margin: "0 auto",
padding: "20px 0 48px",
maxWidth: "560px",
};
const heading = {
fontSize: "24px",
letterSpacing: "-0.5px",
lineHeight: "1.3",
fontWeight: "400",
color: "#484848",
padding: "17px 0 0",
};
const paragraph = {
margin: "0 0 15px",
fontSize: "15px",
lineHeight: "1.4",
color: "#3c4149",
};
const buttonContainer = {
padding: "27px 0 27px",
};
const button = {
backgroundColor: "#0042DA",
borderRadius: "3px",
fontWeight: "600",
color: "#fff",
fontSize: "15px",
textDecoration: "none",
textAlign: "center" as const,
display: "block",
padding: "11px 23px",
};
const reportLink = {
fontSize: "14px",
color: "#b4becc",
};
const hr = {
borderColor: "#dfe1e4",
margin: "42px 0 26px",
};
const codeStyle = {
fontFamily: "monospace",
fontWeight: "700",
padding: "1px 4px",
backgroundColor: "#dfe1e4",
letterSpacing: "-0.3px",
fontSize: "21px",
borderRadius: "4px",
color: "#3c4149",
};
export const sendLoginCodeEmail = async ({
to,
...props
}: Pick<SendMailOptions, "to"> & ComponentProps<typeof LoginCodeEmail>) =>
sendEmail({
to,
subject: "Sign in to Typebot",
html: await render(<LoginCodeEmail {...props} />),
});

View File

@ -0,0 +1,20 @@
import { env } from "@typebot.io/env";
import { type SendMailOptions, createTransport } from "nodemailer";
export const sendEmail = (
props: Pick<SendMailOptions, "to" | "html" | "subject">,
) => {
const transporter = createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
auth: {
user: env.SMTP_USERNAME,
pass: env.SMTP_PASSWORD,
},
});
return transporter.sendMail({
from: env.NEXT_PUBLIC_SMTP_FROM,
...props,
});
};

View File

@ -1,16 +1,17 @@
{
"name": "transactional",
"name": "@typebot.io/transactional",
"type": "module",
"scripts": {
"dev": "SKIP_ENV_CHECK=true dotenv -e ./.env -e ../../.env -- email dev --port=3005 --dir=templates"
"preview": "SKIP_ENV_CHECK=true dotenv -e ./.env -e ../../.env -- email dev --port=3005"
},
"exports": {
"./*": "./src/*.ts"
"./*": "./*.tsx"
},
"dependencies": {
"@react-email/components": "0.0.14",
"@react-email/components": "0.0.25",
"@typebot.io/env": "workspace:*",
"react-email": "2.0.0"
"nodemailer": "6.9.15",
"react-email": "3.0.1"
},
"devDependencies": {
"@typebot.io/tsconfig": "workspace:*",

View File

@ -1,129 +0,0 @@
import {
Body,
Container,
Head,
Heading,
Html,
Img,
Link,
Preview,
Text,
} from "@react-email/components";
import { env } from "@typebot.io/env";
interface Props {
magicLinkUrl: string;
}
const imagesBaseUrl = `${env.NEXTAUTH_URL}/images`;
export const MagicLink = ({ magicLinkUrl }: Props) => (
<Html>
<Head />
<Preview>Log in with this magic link</Preview>
<Body style={main}>
<Container style={container}>
<Img
src={`${imagesBaseUrl}/logo.png`}
width="32"
height="32"
alt="Typebot's Logo"
style={{
margin: "24px 0",
}}
/>
<Heading style={heading}>Your magic link</Heading>
<Link
href={magicLinkUrl}
target="_blank"
style={{
...clickLink,
display: "block",
marginBottom: "24px",
}}
>
👉 Click here to sign in 👈
</Link>
<Text
style={{
...text,
color: "#ababab",
marginTop: "14px",
marginBottom: "16px",
}}
>
If you didn&apos;t try to login, you can safely ignore this email.
</Text>
<Text style={footer}>
<Link
href="https://notion.so"
target="_blank"
style={{ ...link, color: "#898989" }}
>
Typebot.io
</Link>
- Powering Conversations at Scale
</Text>
</Container>
</Body>
</Html>
);
MagicLink.PreviewProps = {
magicLinkUrl: "http://localhost:3000",
} as Props;
export default MagicLink;
const main = {
backgroundColor: "#ffffff",
};
const container = {
paddingLeft: "12px",
paddingRight: "12px",
margin: "0 auto",
};
const clickLink = {
color: "#2754C5",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
fontSize: "18px",
};
const link = {
color: "#2754C5",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
fontSize: "14px",
textDecoration: "underline",
};
const heading = {
color: "#333",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
fontSize: "24px",
fontWeight: "bold",
margin: "32px 0",
padding: "0",
};
const text = {
color: "#333",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
fontSize: "14px",
margin: "24px 0",
};
const footer = {
color: "#898989",
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
fontSize: "12px",
lineHeight: "22px",
marginTop: "12px",
marginBottom: "24px",
};

View File

@ -1,4 +1,7 @@
{
"extends": "@typebot.io/tsconfig/base.json",
"include": ["src/**/*.ts", "src/**/*.tsx"]
"include": ["**/*.ts", "**/*.tsx"],
"compilerOptions": {
"jsx": "react-jsx"
}
}

1945
yarn.lock

File diff suppressed because it is too large Load Diff