diff --git a/README.md b/README.md
index 9698c105..7c2a035c 100644
--- a/README.md
+++ b/README.md
@@ -104,6 +104,16 @@ After you click the "Register application" button you should see the `GITHUB_ID`
After generating the secret, make sure you copy this value to your `.env` file as this value can not be seen again once you refresh the page. 👇

+### Setting up Passwordless auth locally
+
+In order to use Passwordless login locally you need to have a `ACCESS_KEY` and `SECRET_KEY` value.
+
+Niall has written a [tutorial](https://www.codu.co/articles/sending-emails-with-aws-ses-and-nodemailer-in-node-js-xfuucrri) on how to send emails with AWS SES and shows how to get these values.
+
+Check out the example .env file [here](./sample.env) to see how to populate these values
+
+**Note: Currenly the AWS region of the SNS service is hardcoded to "eu-west-1" it may be necessary to change this if your SNS service is in a different region**
+
### NEXTAUTH_URL
You shouldn't need to change the default value here. This is a variable used by Next Auth as the authentication URL to your site.
diff --git a/app/(app)/auth/error/page.tsx b/app/(app)/auth/error/page.tsx
new file mode 100644
index 00000000..1694171e
--- /dev/null
+++ b/app/(app)/auth/error/page.tsx
@@ -0,0 +1,34 @@
+"use client";
+
+import type { NextPage } from "next";
+import { useSearchParams } from "next/navigation";
+import { PostAuthPage } from "../page";
+
+const Page: NextPage = () => {
+ const errorHeadings = {
+ // sign in link already used or expired
+ verification: {
+ heading: "The sign in link is no longer valid",
+ subHeading: "It may have been used already or it may have expired",
+ },
+ // issue with email provider. SNS may be down or something similiar
+ emailsignin: {
+ heading: "Well this is embarrassing",
+ subHeading: "Something unexpected happened, we will look into it",
+ },
+ // if specific error param is not found give the generic
+ unknown: {
+ heading: "Oops... Not sure what happened there",
+ subHeading: "Please try again later",
+ },
+ };
+ // Checking does the error query param exist and if it doesnt replacing with unknown
+ const error = (useSearchParams().get("error")?.toLowerCase() ??
+ "unknown") as keyof typeof errorHeadings;
+ // Checking does the error query param has been covered in errorHeading if not falling back to unknown
+ return PostAuthPage(
+ errorHeadings[error] ? errorHeadings[error] : errorHeadings.unknown,
+ );
+};
+
+export default Page;
diff --git a/app/(app)/auth/page.tsx b/app/(app)/auth/page.tsx
new file mode 100644
index 00000000..7500bd51
--- /dev/null
+++ b/app/(app)/auth/page.tsx
@@ -0,0 +1,72 @@
+"use client";
+
+import type { NextPage } from "next";
+import Link from "next/link";
+import Image from "next/image";
+import { useTheme } from "next-themes";
+import { THEME_MODES } from "@/components/Theme/ThemeToggle/ThemeToggle";
+import { useEffect, useState } from "react";
+
+export const PostAuthPage = (content: {
+ heading: string;
+ subHeading: string;
+}) => {
+ const [mounted, setMounted] = useState(false);
+ const { resolvedTheme } = useTheme();
+
+ // useEffect only happens on client not server
+ useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ // if on server dont render. needed to prevent a hydration mismatch error
+ if (!mounted) return null;
+
+ return (
+
+
+
+ Codú
+
+
+
+
+
+
+ {content.heading}{" "}
+
+
+ {content.subHeading}{" "}
+
+
+
+ Return home →
+
+
+
+
+
+ );
+};
+
+const Auth: NextPage = () => {
+ return PostAuthPage({
+ heading: "Sign in email has been sent",
+ subHeading: "See you soon 🚀",
+ });
+};
+
+export default Auth;
diff --git a/app/(app)/get-started/_client.tsx b/app/(app)/get-started/_client.tsx
index 50c02ec2..07b115ef 100644
--- a/app/(app)/get-started/_client.tsx
+++ b/app/(app)/get-started/_client.tsx
@@ -5,13 +5,14 @@ import Image from "next/image";
import Link from "next/link";
import { signIn } from "next-auth/react";
import { useSearchParams } from "next/navigation";
-
-import { LockClosedIcon } from "@heroicons/react/20/solid";
+import { useEffect, useState } from "react";
const GetStarted: NextPage = () => {
const searchParams = useSearchParams();
const callbackUrl = searchParams?.get("callbackUrl");
+ const [userEmail, setUserEmail] = useState("");
+
const redirectTo =
typeof callbackUrl === "string" ? callbackUrl : "/articles";
@@ -42,39 +43,55 @@ const GetStarted: NextPage = () => {
-
-
-
+ >
+ )}
+
);
diff --git a/components/Theme/ThemeToggle/ThemeToggle.tsx b/components/Theme/ThemeToggle/ThemeToggle.tsx
index 917e252a..53b62c19 100644
--- a/components/Theme/ThemeToggle/ThemeToggle.tsx
+++ b/components/Theme/ThemeToggle/ThemeToggle.tsx
@@ -4,7 +4,7 @@ import { MoonIcon, SunIcon } from "@heroicons/react/20/solid";
import { useTheme } from "next-themes";
import { useEffect, useState } from "react";
-const THEME_MODES = {
+export const THEME_MODES = {
DARK: "dark",
LIGHT: "light",
};
diff --git a/sample.env b/sample.env
index 042dae50..a8f52624 100644
--- a/sample.env
+++ b/sample.env
@@ -1,4 +1,4 @@
GITHUB_ID= ###Â Replace with GitHub OAuth ID (https://github.com/settings/applications/new)
GITHUB_SECRET= ###Â Replace with GitHub OAuth Secret (https://github.com/settings/applications/new)
NEXTAUTH_URL=http://localhost:3000/api/auth
-DATABASE_URL=postgresql://postgres:secret@127.0.0.1:5432/postgres
+DATABASE_URL=postgresql://postgres:secret@127.0.0.1:5432/postgres
\ No newline at end of file
diff --git a/server/auth.ts b/server/auth.ts
index 7ce3dad2..95bd770d 100644
--- a/server/auth.ts
+++ b/server/auth.ts
@@ -1,12 +1,33 @@
-import { type NextAuthOptions, getServerSession } from "next-auth";
+import { type NextAuthOptions, getServerSession, Theme } from "next-auth";
import GitHubProvider from "next-auth/providers/github";
+import EmailProvider, {
+ SendVerificationRequestParams,
+} from "next-auth/providers/email";
import { PrismaAdapter } from "@next-auth/prisma-adapter";
import { createWelcomeEmailTemplate } from "@/utils/createEmailTemplate";
import * as Sentry from "@sentry/nextjs";
import prisma from "@/server/db/client";
-import sendEmail from "@/utils/sendEmail";
+import sendEmail, { nodemailerSesTransporter } from "@/utils/sendEmail";
import { manageNewsletterSubscription } from "./lib/newsletter";
+import { createPasswordLessEmailTemplate } from "@/utils/createPasswordLessEmailTemplate";
+
+const sendPasswordLessEmail = async (params: SendVerificationRequestParams) => {
+ const { identifier, url, provider } = params;
+
+ try {
+ await nodemailerSesTransporter.sendMail({
+ to: identifier,
+ from: process.env.ADMIN_EMAIL,
+ subject: `Sign in to Codú 🚀`,
+ /** Email Text body (fallback for email clients that don't render HTML, e.g. feature phones) */
+ text: `Sign in to Codú 🚀\n\n`,
+ html: createPasswordLessEmailTemplate(url),
+ });
+ } catch (error) {
+ throw new Error(`Sign in email could not be sent`);
+ }
+};
export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
@@ -15,10 +36,16 @@ export const authOptions: NextAuthOptions = {
clientId: process.env.GITHUB_ID || "",
clientSecret: process.env.GITHUB_SECRET || "",
}),
+ EmailProvider({
+ server: {},
+ sendVerificationRequest: sendPasswordLessEmail,
+ }),
],
pages: {
signIn: "/get-started",
newUser: "/settings",
+ verifyRequest: "/auth",
+ error: "/auth/error", // (used for any errors which occur during auth
},
callbacks: {
async session({ session, user }) {
diff --git a/utils/createPasswordLessEmailTemplate.ts b/utils/createPasswordLessEmailTemplate.ts
new file mode 100644
index 00000000..6e31c49f
--- /dev/null
+++ b/utils/createPasswordLessEmailTemplate.ts
@@ -0,0 +1,684 @@
+export const createPasswordLessEmailTemplate = (url: string | null) =>
+ `
+
+
+
+
+
+
+
+
+ New message
+
+
+
+
+
+
+