Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding PasswordLess login to sign in options #785

Merged
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions app/(app)/auth/error/page.tsx
Original file line number Diff line number Diff line change
@@ -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;
72 changes: 72 additions & 0 deletions app/(app)/auth/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="flex w-full flex-grow flex-col justify-center bg-neutral-100 px-4 py-20 dark:bg-black sm:px-6 lg:py-40">
<div className="flex flex-shrink-0 justify-center">
<Link href="/">
<span className="sr-only">Codú</span>
<Image
// Uses black codu logo if in light mode and white codu logo if in dark mode
src={
resolvedTheme === THEME_MODES.LIGHT
? "/images/codu-black.png"
: "/images/codu.png"
}
alt="Codú logo"
height={60}
width={189}
/>
</Link>
</div>
<div className="py-16">
<div className="text-center">
<p className="bg-gradient-to-r from-orange-400 to-pink-600 bg-clip-text text-xl font-semibold uppercase leading-6 tracking-wide text-transparent">
{content.heading}{" "}
</p>
<h1 className="mt-2 text-4xl font-extrabold tracking-tight text-black dark:text-white sm:text-5xl">
{content.subHeading}{" "}
</h1>
<div className="mt-6">
<Link
className="bg-gradient-to-r from-orange-400 to-pink-600 bg-clip-text text-base font-semibold tracking-wide text-transparent"
href="/"
>
Return home<span aria-hidden="true"> &rarr;</span>
</Link>
</div>
</div>
</div>
</main>
);
};

const Auth: NextPage = () => {
return PostAuthPage({
heading: "Sign in email has been sent",
subHeading: "See you soon 🚀",
});
};

export default Auth;
86 changes: 54 additions & 32 deletions app/(app)/get-started/_client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ 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<string>("");
const [isAlpha, setIsAlpha] = useState<boolean>();

useEffect(() => {
// toLowerCase can maybe be removed. Just want to make sure its not TRUE in Prod
setIsAlpha(process.env.ALPHA?.toLowerCase() === "true");
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if this is the correct way to go about alpha stuff. Further guidance needed

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll take a look later today and make a little guide so you can see how I was handling it.

}, []);
const redirectTo =
typeof callbackUrl === "string" ? callbackUrl : "/articles";

Expand Down Expand Up @@ -42,39 +48,55 @@ const GetStarted: NextPage = () => {
</Link>
</p>
</div>

<div>
<button
type="button"
onClick={async () => {
await signIn("github", { callbackUrl: redirectTo });
}}
className="group relative inline-flex w-full justify-center rounded-md border border-transparent bg-gradient-to-r from-orange-400 to-pink-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:from-orange-300 hover:to-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-300 focus:ring-offset-2"
>
<span className="absolute inset-y-0 left-0 flex items-center pl-3">
<LockClosedIcon
className="h-5 w-5 text-orange-600 group-hover:text-white"
aria-hidden="true"
{isAlpha && (
<>
<div>
<input
className="w-full flex-auto appearance-none rounded-md border-neutral-200 bg-white pl-6 font-medium text-neutral-950 ring-offset-0 placeholder:text-xl placeholder:text-neutral-500 focus:outline-none focus:ring-2 focus:ring-pink-300 focus:ring-transparent focus:ring-offset-2 dark:border-none dark:bg-neutral-900 dark:bg-neutral-900 dark:text-white dark:text-white [&::-webkit-search-cancel-button]:hidden [&::-webkit-search-decoration]:hidden [&::-webkit-search-results-button]:hidden [&::-webkit-search-results-decoration]:hidden"
placeholder="Enter your email"
onChange={(event) => {
setUserEmail(event.target.value);
}}
value={userEmail}
/>
</span>
<span className="absolute inset-y-0 right-0 flex items-center pr-3">
<span className="sr-only">Sign in with GitHub</span>
<svg
className="h-5 w-5 text-pink-800 group-hover:text-white"
<button
type="button"
disabled={!userEmail}
onClick={async () => {
await signIn("email", {
callbackUrl: redirectTo,
email: userEmail,
});
}}
className="group relative mt-6 inline-flex w-full justify-center rounded-md border border-transparent bg-gradient-to-r from-orange-400 to-pink-600 px-4 py-2 text-xl font-medium text-white shadow-sm hover:from-orange-300 hover:to-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-300 focus:ring-offset-2"
>
Sign In / Sign Up
</button>
</div>
<div className="relative">
<div
className="absolute inset-0 flex items-center"
aria-hidden="true"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z"
clipRule="evenodd"
/>
</svg>
</span>
Login with GitHub
</button>
</div>
<div className="w-full border-t border-black dark:border-gray-200" />
</div>
<div className="relative flex justify-center text-sm font-medium leading-6">
<span className="bg-neutral-100 px-6 text-xl text-neutral-900 dark:bg-black dark:text-white">
Or continue with
</span>
</div>
</div>
</>
)}
<button
type="button"
onClick={async () => {
await signIn("github", { callbackUrl: redirectTo });
}}
className="group relative inline-flex w-full justify-center rounded-md border border-transparent bg-gradient-to-r from-orange-400 to-pink-600 px-4 py-2 text-xl font-medium text-white shadow-sm hover:from-orange-300 hover:to-pink-500 focus:outline-none focus:ring-2 focus:ring-pink-300 focus:ring-offset-2"
>
GitHub
</button>
</div>
</div>
);
Expand Down
2 changes: 1 addition & 1 deletion components/Theme/ThemeToggle/ThemeToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed this exported so I could use it to determine the coco log that should be displayed. ie black or white

DARK: "dark",
LIGHT: "light",
};
Expand Down
6 changes: 6 additions & 0 deletions sample.env
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,9 @@ GITHUB_ID= ### Replace with GitHub OAuth ID (https://github.com/settings/applic
GITHUB_SECRET= ### Replace with GitHub OAuth Secret (https://github.com/settings/applications/new)
NEXTAUTH_URL=http://localhost:3000/api/auth
DATABASE_URL=postgresql://postgres:[email protected]:5432/postgres

EMAIL_SERVER_USER= ### Replace with SNS username
EMAIL_SERVER_PASSWORD= ### Replace with SNS password
EMAIL_SERVER_HOST= ### Replace with AWS host
EMAIL_SERVER_PORT=587
EMAIL_FROM= ### Replace with email address to deliver passwordless email from
39 changes: 38 additions & 1 deletion server/auth.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,35 @@
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 { manageNewsletterSubscription } from "./lib/newsletter";
import { createTransport } from "nodemailer";
import { createPasswordLessEmailTemplate } from "@/utils/createPasswordLessEmailTemplate";

const sendPasswordLessEmail = async (params: SendVerificationRequestParams) => {
const { identifier, url, provider } = params;
const transport = createTransport(provider.server);

const result = await transport.sendMail({
to: identifier,
from: provider.from,
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),
});
const failed = result.rejected.concat(result.pending).filter(Boolean);
if (failed.length) {
throw new Error(`Email(s) (${failed.join(", ")}) could not be sent`);
}
};

export const authOptions: NextAuthOptions = {
adapter: PrismaAdapter(prisma),
Expand All @@ -15,10 +38,24 @@ export const authOptions: NextAuthOptions = {
clientId: process.env.GITHUB_ID || "",
clientSecret: process.env.GITHUB_SECRET || "",
}),
EmailProvider({
server: {
host: process.env.EMAIL_SERVER_HOST,
port: process.env.EMAIL_SERVER_PORT,
auth: {
user: process.env.EMAIL_SERVER_USER,
pass: process.env.EMAIL_SERVER_PASSWORD,
},
},
sendVerificationRequest: sendPasswordLessEmail,
from: process.env.EMAIL_FROM,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a new variable? Could we use ADMIN_EMAIL. I think this is the very last thing (I kinda promise 😂).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah good point. Lemme change that

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually even better I think its not needed. Will confirm

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@NiallJoeMaher sorted in #19379a8

}),
],
pages: {
signIn: "/get-started",
newUser: "/settings",
verifyRequest: "/auth",
error: "/auth/error", // (used for any errors which occur during auth
},
callbacks: {
async session({ session, user }) {
Expand Down
Loading