-
Notifications
You must be signed in to change notification settings - Fork 10
Verify user emails #1151
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
Verify user emails #1151
Changes from all commits
edb53e5
27c93f2
e4e1805
fc3e71a
e0e46fc
12bc2c3
641ebae
9d09133
65dd3b2
faa0f9e
a54eb9b
2a01202
1862549
81e6e7b
63e97a0
4afc8a1
e04d409
9acc9f6
8826baa
8f5ac9b
7cacc2b
20fa87c
01f67d2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,8 +6,9 @@ import { NextResponse } from "next/server"; | |
| import type { AuthTokenType } from "db/public"; | ||
| import { logger } from "logger"; | ||
|
|
||
| import { db } from "~/kysely/database"; | ||
| import { lucia } from "~/lib/authentication/lucia"; | ||
| import { env } from "~/lib/env/env.mjs"; | ||
| import { createRedirectUrl } from "~/lib/redirect"; | ||
| import { InvalidTokenError, TokenFailureReason, validateToken } from "~/lib/server/token"; | ||
|
|
||
| const redirectToURL = ( | ||
|
|
@@ -16,30 +17,8 @@ const redirectToURL = ( | |
| searchParams?: Record<string, string>; | ||
| } | ||
| ) => { | ||
| // it's a full url, just redirect them there | ||
| if (URL.canParse(redirectTo)) { | ||
| const url = new URL(redirectTo); | ||
| Object.entries(opts?.searchParams ?? {}).forEach(([key, value]) => { | ||
| url.searchParams.append(key, value); | ||
| }); | ||
|
|
||
| return NextResponse.redirect(url, opts); | ||
| } | ||
|
|
||
| if (URL.canParse(redirectTo, env.PUBPUB_URL)) { | ||
| const url = new URL(redirectTo, env.PUBPUB_URL); | ||
|
|
||
| Object.entries(opts?.searchParams ?? {}).forEach(([key, value]) => { | ||
| url.searchParams.append(key, value); | ||
| }); | ||
| return NextResponse.redirect(url, opts); | ||
| } | ||
|
|
||
| // invalid redirectTo, redirect to not-found | ||
| return NextResponse.redirect( | ||
| new URL(`/not-found?from=${encodeURIComponent(redirectTo)}`, env.PUBPUB_URL), | ||
| opts | ||
| ); | ||
| const url = createRedirectUrl(redirectTo, opts?.searchParams); | ||
| return NextResponse.redirect(url, opts); | ||
| }; | ||
|
|
||
| /** | ||
|
|
@@ -129,6 +108,14 @@ export async function GET(req: NextRequest) { | |
|
|
||
| const { user: tokenUser, authTokenType } = tokenSettled.value; | ||
|
|
||
| if (!tokenUser.isVerified) { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. by putting this here in the |
||
| await db | ||
| .updateTable("users") | ||
| .set({ isVerified: true }) | ||
| .where("id", "=", tokenUser.id) | ||
| .execute(); | ||
| } | ||
|
|
||
| const session = await lucia.createSession(tokenUser.id, { | ||
| type: authTokenType, | ||
| }); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| "use client"; | ||
|
|
||
| import { useState } from "react"; | ||
|
|
||
| import type { ButtonState } from "~/app/components/SubmitButton"; | ||
| import { SubmitButton } from "~/app/components/SubmitButton"; | ||
| import { sendVerifyEmailMail } from "~/lib/authentication/actions"; | ||
| import { useServerAction } from "~/lib/serverActions"; | ||
|
|
||
| export const ResendVerificationButton = ({ | ||
| email, | ||
| redirectTo, | ||
| }: { | ||
| email: string; | ||
| redirectTo?: string; | ||
| }) => { | ||
| const [status, setStatus] = useState<ButtonState>("idle"); | ||
| const sendVerifyEmail = useServerAction(sendVerifyEmailMail); | ||
|
|
||
| const handleResend = async () => { | ||
| setStatus("loading"); | ||
| const result = await sendVerifyEmail({ email, redirectTo }); | ||
| if ("error" in result) { | ||
| setStatus("error"); | ||
| } else { | ||
| setStatus("success"); | ||
| } | ||
| }; | ||
|
|
||
| return ( | ||
| <SubmitButton | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. very handy button!! |
||
| state={status} | ||
| onClick={handleResend} | ||
| idleText="Resend verification email" | ||
| loadingText="Sending..." | ||
| /> | ||
| ); | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| import { redirect } from "next/navigation"; | ||
|
|
||
| import { AuthTokenType } from "db/public"; | ||
|
|
||
| import { getLoginData } from "~/lib/authentication/loginData"; | ||
| import { createRedirectUrl } from "~/lib/redirect"; | ||
| import { TokenFailureReason } from "~/lib/server/token"; | ||
| import { ResendVerificationButton } from "./ResendVerificationButton"; | ||
|
|
||
| type SearchParams = | ||
| | { | ||
| redirectTo?: string; | ||
| } | ||
| | { | ||
| redirectTo?: string; | ||
| token: string; | ||
| reason: string; | ||
| }; | ||
|
|
||
| export default async function Page({ searchParams }: { searchParams: Promise<SearchParams> }) { | ||
| const { user, session } = await getLoginData({ | ||
| allowedSessions: [AuthTokenType.generic, AuthTokenType.verifyEmail], | ||
| }); | ||
|
|
||
| const { redirectTo, ...search } = await searchParams; | ||
|
|
||
| if (!user || !session) { | ||
| const verifyUrl = redirectTo ? `/verify?redirectTo=${redirectTo}` : "/verify"; | ||
| redirect(`/login?redirectTo=${encodeURIComponent(verifyUrl)}`); | ||
| } | ||
|
|
||
| let description = "Check your email and click the link to verify your email address."; | ||
|
|
||
| if ("reason" in search && search.reason === TokenFailureReason.expired) { | ||
| description = "Your token has expired. Please request a new one."; | ||
| } | ||
|
|
||
| if (user.isVerified) { | ||
| const url = redirectTo | ||
| ? createRedirectUrl(redirectTo, { verified: "true" }) | ||
| : "/?verified=true"; | ||
| redirect(url.toString()); | ||
| } | ||
|
|
||
| return ( | ||
| <div className="prose mx-auto max-w-sm"> | ||
| <h1>Verify your email</h1> | ||
| <p>{description}</p> | ||
| <ResendVerificationButton email={user.email} redirectTo={redirectTo} /> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect } from "react"; | ||
| import { CircleCheck } from "lucide-react"; | ||
| import { parseAsBoolean, useQueryStates } from "nuqs"; | ||
|
|
||
| import type { ToasterToast } from "ui/use-toast"; | ||
| import { Toaster } from "ui/toaster"; | ||
| import { toast } from "ui/use-toast"; | ||
|
|
||
| import { entries, fromEntries, keys } from "~/lib/mapping"; | ||
|
|
||
| const PERSISTED_TOAST = { | ||
| verified: { | ||
| title: "Verified", | ||
| description: ( | ||
| <span className="flex items-center gap-1"> | ||
| <CircleCheck size="16" /> Your email is now verified | ||
| </span> | ||
| ), | ||
| variant: "success", | ||
| }, | ||
| } as const satisfies { [key: string]: Omit<ToasterToast, "id"> }; | ||
|
|
||
| const usePersistedToasts = () => { | ||
| const toastQueries = fromEntries( | ||
| keys(PERSISTED_TOAST).map((key) => [key, parseAsBoolean.withDefault(false)]) | ||
| ); | ||
|
|
||
| const [params, setParams] = useQueryStates(toastQueries, { | ||
| history: "replace", | ||
| scroll: false, | ||
| }); | ||
| const activeToasts = entries(params) | ||
| .filter(([param, active]) => active) | ||
| .map(([param]) => param); | ||
|
|
||
| useEffect(() => { | ||
| for (const activeToastKey of activeToasts) { | ||
| const toastData = PERSISTED_TOAST[activeToastKey]; | ||
| toast(toastData); | ||
| setParams({ | ||
| [activeToastKey]: null, | ||
| }); | ||
| } | ||
| }, [activeToasts]); | ||
| }; | ||
|
|
||
| export const RootToaster = () => { | ||
| usePersistedToasts(); | ||
|
|
||
| return <Toaster />; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,15 @@ | ||
| import { NuqsAdapter } from "nuqs/adapters/next/app"; | ||
|
|
||
| import { Toaster } from "ui/toaster"; | ||
|
|
||
| import "ui/styles.css"; | ||
|
|
||
| import { Suspense } from "react"; | ||
|
|
||
| // import "./globals.css"; | ||
|
|
||
| import { TooltipProvider } from "ui/tooltip"; | ||
|
|
||
| import { ReactQueryProvider } from "./components/providers/QueryProvider"; | ||
| import { RootToaster } from "./RootToaster"; | ||
|
|
||
| export const metadata = { | ||
| title: "PubPub Platform", | ||
|
|
@@ -23,7 +24,9 @@ export default function RootLayout({ children }: { children: React.ReactNode }) | |
| <NuqsAdapter> | ||
| <TooltipProvider> | ||
| {children} | ||
| <Toaster /> | ||
| <Suspense> | ||
| <RootToaster /> | ||
| </Suspense> | ||
|
Comment on lines
-26
to
+29
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. hopefully this is okay—I wasn't sure how else to get the toast after redirect to work. it needs
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is correct! Not sure if their are any repercussions from this, but for now it seems fine |
||
| </TooltipProvider> | ||
| </NuqsAdapter> | ||
| </ReactQueryProvider> | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,23 +1,36 @@ | ||
| import { cookies } from "next/headers"; | ||
| import { redirect } from "next/navigation"; | ||
|
|
||
| import { AuthTokenType } from "db/public"; | ||
|
|
||
| import { LAST_VISITED_COOKIE } from "~/app/components/LastVisitedCommunity/constants"; | ||
| import { getPageLoginData } from "~/lib/authentication/loginData"; | ||
| import { createRedirectUrl } from "~/lib/redirect"; | ||
|
|
||
| export default async function Page({ | ||
| searchParams, | ||
| }: { | ||
| searchParams: Promise<Record<string, string>>; | ||
| }) { | ||
| const { user, session } = await getPageLoginData(); | ||
|
|
||
| export default async function Page() { | ||
| const { user } = await getPageLoginData(); | ||
| const params = await searchParams; | ||
|
|
||
| if (!user) { | ||
| redirect("/login"); | ||
| redirect(createRedirectUrl("/login", params).toString()); | ||
| } | ||
|
|
||
| if (session.type === AuthTokenType.verifyEmail) { | ||
| redirect(createRedirectUrl("/verify", params).toString()); | ||
| } | ||
|
|
||
| const cookieStore = await cookies(); | ||
| const lastVisited = cookieStore.get(LAST_VISITED_COOKIE); | ||
| const communitySlug = lastVisited?.value ?? user.memberships[0]?.community?.slug; | ||
|
|
||
| if (!communitySlug) { | ||
| redirect("/settings"); | ||
| redirect(createRedirectUrl("/settings", params).toString()); | ||
| } | ||
|
|
||
| redirect(`/c/${communitySlug}/stages`); | ||
| redirect(createRedirectUrl(`/c/${communitySlug}/stages`, params).toString()); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. the regular pages weren't passing on search params. I needed it to so that |
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Moved this to a helper file since I found I needed it elsewhere (mostly to easily tack on search params). but I think @tefkah 's lil routes lib would be a lot nicer