improve verify email redirect flow

This commit is contained in:
Milo Schwartz 2024-11-28 00:11:13 -05:00
parent c2cbd7e1a1
commit 5bbf32f6a6
No known key found for this signature in database
18 changed files with 145 additions and 83 deletions

View file

@ -21,9 +21,7 @@ export async function sendEmail(
return; return;
} }
logger.debug("Rendering email templatee...")
const emailHtml = await render(template); const emailHtml = await render(template);
logger.debug("Done rendering email templatee")
const options = { const options = {
from: opts.from, from: opts.from,

View file

@ -33,9 +33,17 @@ export const SendInviteLink = ({
<Html> <Html>
<Head /> <Head />
<Preview>{previewText}</Preview> <Preview>{previewText}</Preview>
<Tailwind> <Tailwind config={{
theme: {
extend: {
colors: {
primary: "#F97317"
}
}
}
}}>
<Body className="font-sans"> <Body className="font-sans">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8"> <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<Heading className="text-2xl font-semibold text-gray-800 text-center"> <Heading className="text-2xl font-semibold text-gray-800 text-center">
You're invited to join a Fossorial organization You're invited to join a Fossorial organization
</Heading> </Heading>
@ -58,7 +66,7 @@ export const SendInviteLink = ({
<Section className="text-center my-6"> <Section className="text-center my-6">
<Button <Button
href={inviteLink} href={inviteLink}
className="rounded-md bg-gray-600 px-[12px] py-[12px] text-center font-semibold text-white cursor-pointer" className="rounded-lg bg-primary px-[12px] py-[9px] text-center font-semibold text-white cursor-pointer"
> >
Accept invitation to {orgName} Accept invitation to {orgName}
</Button> </Button>

View file

@ -14,11 +14,13 @@ import * as React from "react";
interface VerifyEmailProps { interface VerifyEmailProps {
username?: string; username?: string;
verificationCode: string; verificationCode: string;
verifyLink: string;
} }
export const VerifyEmail = ({ export const VerifyEmail = ({
username, username,
verificationCode, verificationCode,
verifyLink,
}: VerifyEmailProps) => { }: VerifyEmailProps) => {
const previewText = `Verify your email, ${username}`; const previewText = `Verify your email, ${username}`;
@ -26,21 +28,34 @@ export const VerifyEmail = ({
<Html> <Html>
<Head /> <Head />
<Preview>{previewText}</Preview> <Preview>{previewText}</Preview>
<Tailwind> <Tailwind
config={{
theme: {
extend: {
colors: {
primary: "#F97317",
},
},
},
}}
>
<Body className="font-sans"> <Body className="font-sans">
<Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8"> <Container className="bg-white border border-solid border-gray-200 p-6 max-w-lg mx-auto my-8 rounded-lg">
<Heading className="text-2xl font-semibold text-gray-800 text-center"> <Heading className="text-2xl font-semibold text-gray-800 text-center">
Verify Your Email Please verify your email
</Heading> </Heading>
<Text className="text-base text-gray-700 mt-4"> <Text className="text-base text-gray-700 mt-4">
Hi {username || "there"}, Hi {username || "there"},
</Text> </Text>
<Text className="text-base text-gray-700 mt-2"> <Text className="text-base text-gray-700 mt-2">
Youve requested to verify your email. Please use Youve requested to verify your email. Please{" "}
the verification code below: <a href={verifyLink} className="text-primary">
click here
</a>{" "}
to verify your email, then enter the following code:
</Text> </Text>
<Section className="text-center my-6"> <Section className="text-center my-6">
<Text className="inline-block bg-gray-100 text-xl font-bold text-gray-900 py-2 px-4 border border-gray-300 rounded-md"> <Text className="inline-block bg-primary text-xl font-bold text-white py-2 px-4 border border-gray-300 rounded-xl">
{verificationCode} {verificationCode}
</Text> </Text>
</Section> </Section>
@ -59,3 +74,5 @@ export const VerifyEmail = ({
</Html> </Html>
); );
}; };
export default VerifyEmail;

View file

@ -139,7 +139,7 @@ export async function login(
success: true, success: true,
error: false, error: false,
message: "Email verification code sent", message: "Email verification code sent",
status: HttpCode.ACCEPTED, status: HttpCode.OK,
}); });
} }

View file

@ -13,11 +13,18 @@ export async function sendEmailVerificationCode(
): Promise<void> { ): Promise<void> {
const code = await generateEmailVerificationCode(userId, email); const code = await generateEmailVerificationCode(userId, email);
await sendEmail(VerifyEmail({ username: email, verificationCode: code }), { await sendEmail(
to: email, VerifyEmail({
from: config.email?.no_reply, username: email,
subject: "Verify your email address", verificationCode: code,
}); verifyLink: `${config.app.base_url}/auth/verify-email`,
}),
{
to: email,
from: config.email?.no_reply,
subject: "Verify your email address",
},
);
} }
async function generateEmailVerificationCode( async function generateEmailVerificationCode(

View file

@ -1,6 +1,8 @@
import { internal } from "@app/api"; import { internal } from "@app/api";
import { authCookieHeader } from "@app/api/cookies"; import { authCookieHeader } from "@app/api/cookies";
import { verifySession } from "@app/lib/auth/verifySession";
import { GetOrgResponse } from "@server/routers/org"; import { GetOrgResponse } from "@server/routers/org";
import { GetOrgUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { cache } from "react"; import { cache } from "react";
@ -17,6 +19,25 @@ export default async function OrgLayout(props: {
redirect(`/`); redirect(`/`);
} }
const getUser = cache(verifySession);
const user = await getUser();
if (!user) {
redirect(`/?redirect=/${orgId}`);
}
try {
const getOrgUser = cache(() =>
internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${orgId}/user/${user.userId}`,
cookie,
),
);
const orgUser = await getOrgUser();
} catch {
redirect(`/`);
}
try { try {
const getOrg = cache(() => const getOrg = cache(() =>
internal.get<AxiosResponse<GetOrgResponse>>( internal.get<AxiosResponse<GetOrgResponse>>(

View file

@ -14,27 +14,6 @@ export default async function OrgPage(props: OrgPageProps) {
const params = await props.params; const params = await props.params;
const orgId = params.orgId; const orgId = params.orgId;
const getUser = cache(verifySession);
const user = await getUser();
if (!user) {
redirect("/auth/login");
}
const cookie = await authCookieHeader();
try {
const getOrgUser = cache(() =>
internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${orgId}/user/${user.userId}`,
cookie
)
);
const orgUser = await getOrgUser();
} catch {
redirect(`/`);
}
return ( return (
<> <>
<p>Welcome to {orgId} dashboard</p> <p>Welcome to {orgId} dashboard</p>

View file

@ -26,7 +26,7 @@ export default async function GeneralSettingsPage({
const user = await getUser(); const user = await getUser();
if (!user) { if (!user) {
redirect("/auth/login"); redirect(`/?redirect=/${orgId}/settings/general`);
} }
let orgUser = null; let orgUser = null;
@ -34,8 +34,8 @@ export default async function GeneralSettingsPage({
const getOrgUser = cache(async () => const getOrgUser = cache(async () =>
internal.get<AxiosResponse<GetOrgUserResponse>>( internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${orgId}/user/${user.userId}`, `/org/${orgId}/user/${user.userId}`,
await authCookieHeader() await authCookieHeader(),
) ),
); );
const res = await getOrgUser(); const res = await getOrgUser();
orgUser = res.data.data; orgUser = res.data.data;
@ -48,8 +48,8 @@ export default async function GeneralSettingsPage({
const getOrg = cache(async () => const getOrg = cache(async () =>
internal.get<AxiosResponse<GetOrgResponse>>( internal.get<AxiosResponse<GetOrgResponse>>(
`/org/${orgId}`, `/org/${orgId}`,
await authCookieHeader() await authCookieHeader(),
) ),
); );
const res = await getOrg(); const res = await getOrg();
org = res.data.data; org = res.data.data;

View file

@ -55,7 +55,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const user = await getUser(); const user = await getUser();
if (!user) { if (!user) {
redirect("/auth/login"); redirect(`/?redirect=/${params.orgId}/`);
} }
const cookie = await authCookieHeader(); const cookie = await authCookieHeader();
@ -64,8 +64,8 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
const getOrgUser = cache(() => const getOrgUser = cache(() =>
internal.get<AxiosResponse<GetOrgUserResponse>>( internal.get<AxiosResponse<GetOrgUserResponse>>(
`/org/${params.orgId}/user/${user.userId}`, `/org/${params.orgId}/user/${user.userId}`,
cookie cookie,
) ),
); );
const orgUser = await getOrgUser(); const orgUser = await getOrgUser();
@ -79,7 +79,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
let orgs: ListOrgsResponse["orgs"] = []; let orgs: ListOrgsResponse["orgs"] = [];
try { try {
const getOrgs = cache(() => const getOrgs = cache(() =>
internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie) internal.get<AxiosResponse<ListOrgsResponse>>(`/orgs`, cookie),
); );
const res = await getOrgs(); const res = await getOrgs();
if (res && res.data.data.orgs) { if (res && res.data.data.orgs) {

View file

@ -30,7 +30,15 @@ export default function DashboardLoginForm({
<CardContent> <CardContent>
<LoginForm <LoginForm
redirect={redirect} redirect={redirect}
onLogin={() => router.push("/")} onLogin={() => {
if (redirect && redirect.includes("http")) {
window.location.href = redirect;
} else if (redirect) {
router.push(redirect);
} else {
router.push("/");
}
}}
/> />
</CardContent> </CardContent>
</Card> </Card>

View file

@ -38,6 +38,7 @@ import { LoginResponse } from "@server/routers/auth";
import ResourceAccessDenied from "./ResourceAccessDenied"; import ResourceAccessDenied from "./ResourceAccessDenied";
import LoginForm from "@app/components/LoginForm"; import LoginForm from "@app/components/LoginForm";
import { AuthWithPasswordResponse } from "@server/routers/resource"; import { AuthWithPasswordResponse } from "@server/routers/resource";
import { redirect } from "next/dist/server/api-utils";
const pinSchema = z.object({ const pinSchema = z.object({
pin: z pin: z
@ -113,11 +114,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
}, },
}); });
function constructRedirect(redirect: string): string {
const redirectUrl = new URL(redirect);
return redirectUrl.toString();
}
const onPinSubmit = (values: z.infer<typeof pinSchema>) => { const onPinSubmit = (values: z.infer<typeof pinSchema>) => {
setLoadingLogin(true); setLoadingLogin(true);
api.post<AxiosResponse<AuthWithPasswordResponse>>( api.post<AxiosResponse<AuthWithPasswordResponse>>(
@ -127,9 +123,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
.then((res) => { .then((res) => {
const session = res.data.data.session; const session = res.data.data.session;
if (session) { if (session) {
const url = constructRedirect(props.redirect); window.location.href = props.redirect;
console.log(url);
window.location.href = url;
} }
}) })
.catch((e) => { .catch((e) => {
@ -152,7 +146,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
.then((res) => { .then((res) => {
const session = res.data.data.session; const session = res.data.data.session;
if (session) { if (session) {
window.location.href = constructRedirect(props.redirect); window.location.href = props.redirect;
} }
}) })
.catch((e) => { .catch((e) => {
@ -172,7 +166,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
} }
if (!accessDenied) { if (!accessDenied) {
window.location.href = constructRedirect(props.redirect); window.location.href = props.redirect;
} }
} }
@ -371,6 +365,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
className={`${numMethods <= 1 ? "mt-0" : ""}`} className={`${numMethods <= 1 ? "mt-0" : ""}`}
> >
<LoginForm <LoginForm
redirect={window.location.href}
onLogin={async () => onLogin={async () =>
await handleSSOAuth() await handleSSOAuth()
} }

View file

@ -79,6 +79,7 @@ export default function VerifyEmailForm({
.catch((e) => { .catch((e) => {
setError(formatAxiosError(e, "An error occurred")); setError(formatAxiosError(e, "An error occurred"));
console.error("Failed to verify email:", e); console.error("Failed to verify email:", e);
setIsSubmitting(false);
}); });
if (res && res.data?.data?.valid) { if (res && res.data?.data?.valid) {
@ -125,7 +126,7 @@ export default function VerifyEmailForm({
<div> <div>
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
<CardTitle>Verify Your Email</CardTitle> <CardTitle>Verify Email</CardTitle>
<CardDescription> <CardDescription>
Enter the verification code sent to your email address. Enter the verification code sent to your email address.
</CardDescription> </CardDescription>
@ -234,7 +235,7 @@ export default function VerifyEmailForm({
</CardContent> </CardContent>
</Card> </Card>
<div className="text-center text-muted-foreground mt-4"> <div className="text-center text-muted-foreground mt-2">
<Button <Button
type="button" type="button"
variant="link" variant="link"

View file

@ -8,13 +8,13 @@ export const dynamic = "force-dynamic";
export default async function Page(props: { export default async function Page(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) { }) {
if (process.env.PUBLIC_FLAGS_EMAIL_VERIFICATION_REQUIRED !== "true") { if (process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED !== "true") {
redirect("/"); redirect("/");
} }
const searchParams = await props.searchParams; const searchParams = await props.searchParams;
const getUser = cache(verifySession); const getUser = cache(verifySession);
const user = await getUser(); const user = await getUser({ skipCheckVerifyEmail: true });
if (!user) { if (!user) {
redirect("/"); redirect("/");

View file

@ -21,7 +21,7 @@ export default async function InvitePage(props: {
const user = await verifySession(); const user = await verifySession();
if (!user) { if (!user) {
redirect(`/auth/login?redirect=/invite?token=${params.token}`); redirect(`/?redirect=/invite?token=${params.token}`);
} }
const parts = tokenParam.split("-"); const parts = tokenParam.split("-");

View file

@ -12,21 +12,36 @@ import { cache } from "react";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function Page(props: { export default async function Page(props: {
searchParams: Promise<{ [key: string]: string | string[] | undefined }>; searchParams: Promise<{ redirect: string | undefined }>;
}) { }) {
const params = await props.searchParams; // this is needed to prevent static optimization const params = await props.searchParams; // this is needed to prevent static optimization
const getUser = cache(verifySession); const getUser = cache(verifySession);
const user = await getUser(); const user = await getUser({ skipCheckVerifyEmail: true });
if (!user) { if (!user) {
redirect("/auth/login"); if (params.redirect) {
redirect(`/auth/login?redirect=${params.redirect}`);
} else {
redirect(`/auth/login`);
}
}
if (
!user.emailVerified &&
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED === "true"
) {
if (params.redirect) {
redirect(`/auth/verify-email?redirect=${params.redirect}`);
} else {
redirect(`/auth/verify-email`);
}
} }
let orgs: ListOrgsResponse["orgs"] = []; let orgs: ListOrgsResponse["orgs"] = [];
try { try {
const res = await internal.get<AxiosResponse<ListOrgsResponse>>( const res = await internal.get<AxiosResponse<ListOrgsResponse>>(
`/orgs`, `/orgs`,
await authCookieHeader() await authCookieHeader(),
); );
if (res && res.data.data.orgs) { if (res && res.data.data.orgs) {

View file

@ -19,7 +19,7 @@ export default async function SetupLayout({
const user = await getUser(); const user = await getUser();
if (!user) { if (!user) {
redirect("/"); redirect("/?redirect=/setup");
} }
return <div className="mt-32">{children}</div>; return <div className="mt-32">{children}</div>;

View file

@ -72,13 +72,14 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
); );
}); });
console.log(res);
if (res && res.status === 200) { if (res && res.status === 200) {
setError(null); setError(null);
console.log(res);
if (res.data?.data?.emailVerificationRequired) { if (res.data?.data?.emailVerificationRequired) {
if (redirect) { if (redirect) {
console.log("here", redirect)
router.push(`/auth/verify-email?redirect=${redirect}`); router.push(`/auth/verify-email?redirect=${redirect}`);
} else { } else {
router.push("/auth/verify-email"); router.push("/auth/verify-email");
@ -86,14 +87,8 @@ export default function LoginForm({ redirect, onLogin }: LoginFormProps) {
return; return;
} }
if (redirect && redirect.includes("http")) { if (onLogin) {
window.location.href = redirect; await onLogin();
} else if (redirect) {
router.push(redirect);
} else {
if (onLogin) {
await onLogin();
}
} }
} }

View file

@ -3,15 +3,33 @@ import { authCookieHeader } from "@app/api/cookies";
import { GetUserResponse } from "@server/routers/user"; import { GetUserResponse } from "@server/routers/user";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
export async function verifySession(): Promise<GetUserResponse | null> { export async function verifySession({
skipCheckVerifyEmail,
}: {
skipCheckVerifyEmail?: boolean;
} = {}): Promise<GetUserResponse | null> {
try { try {
const res = await internal.get<AxiosResponse<GetUserResponse>>( const res = await internal.get<AxiosResponse<GetUserResponse>>(
"/user", "/user",
await authCookieHeader() await authCookieHeader(),
); );
return res.data.data; const user = res.data.data;
} catch {
if (!user) {
return null;
}
if (
!skipCheckVerifyEmail &&
!user.emailVerified &&
process.env.FLAGS_EMAIL_VERIFICATION_REQUIRED == "true"
) {
return null;
}
return user;
} catch (e) {
return null; return null;
} }
} }