diff --git a/core/app/(user)/magic-link/route.ts b/core/app/(user)/magic-link/route.ts index 593c606b08..70c085f6d3 100644 --- a/core/app/(user)/magic-link/route.ts +++ b/core/app/(user)/magic-link/route.ts @@ -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; } ) => { - // 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) { + await db + .updateTable("users") + .set({ isVerified: true }) + .where("id", "=", tokenUser.id) + .execute(); + } + const session = await lucia.createSession(tokenUser.id, { type: authTokenType, }); diff --git a/core/app/(user)/verify/ResendVerificationButton.tsx b/core/app/(user)/verify/ResendVerificationButton.tsx new file mode 100644 index 0000000000..e0a91a671a --- /dev/null +++ b/core/app/(user)/verify/ResendVerificationButton.tsx @@ -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("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 ( + + ); +}; diff --git a/core/app/(user)/verify/page.tsx b/core/app/(user)/verify/page.tsx new file mode 100644 index 0000000000..799b966a54 --- /dev/null +++ b/core/app/(user)/verify/page.tsx @@ -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 }) { + 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 ( +
+

Verify your email

+

{description}

+ +
+ ); +} diff --git a/core/app/RootToaster.tsx b/core/app/RootToaster.tsx new file mode 100644 index 0000000000..c4036f12e3 --- /dev/null +++ b/core/app/RootToaster.tsx @@ -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: ( + + Your email is now verified + + ), + variant: "success", + }, +} as const satisfies { [key: string]: Omit }; + +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 ; +}; diff --git a/core/app/c/(public)/[communitySlug]/public/signup/page.tsx b/core/app/c/(public)/[communitySlug]/public/signup/page.tsx index deeface7a0..4367fab630 100644 --- a/core/app/c/(public)/[communitySlug]/public/signup/page.tsx +++ b/core/app/c/(public)/[communitySlug]/public/signup/page.tsx @@ -38,8 +38,6 @@ export default async function Page({ if (user) { if (user.memberships.some((m) => m.communityId === community.id)) { redirect(redirectTo ?? `/c/${community.slug}/stages`); - // TODO: redirect to wherever they were redirected to before signing up - throw new Error("User is already member of community"); } // TODO: figure this out based on the invite diff --git a/core/app/components/Signup/BaseSignupForm.tsx b/core/app/components/Signup/BaseSignupForm.tsx index d9161d99b1..72e725aa72 100644 --- a/core/app/components/Signup/BaseSignupForm.tsx +++ b/core/app/components/Signup/BaseSignupForm.tsx @@ -124,12 +124,6 @@ export function BaseSignupForm(props: { idleText="Finish sign up" /> - {/*
- Already have an account?{" "} - - Sign in - -
*/} Or{" "} diff --git a/core/app/components/SubmitButton.tsx b/core/app/components/SubmitButton.tsx index e6055aba5c..672add049c 100644 --- a/core/app/components/SubmitButton.tsx +++ b/core/app/components/SubmitButton.tsx @@ -6,7 +6,7 @@ import { CheckCircle, Loader2, XCircle } from "lucide-react"; import { Button } from "ui/button"; import { cn } from "utils"; -type ButtonState = "idle" | "loading" | "success" | "error"; +export type ButtonState = "idle" | "loading" | "success" | "error"; type SubmitButtonProps = { // direct control props diff --git a/core/app/layout.tsx b/core/app/layout.tsx index fbabcfc21e..640cfa9f93 100644 --- a/core/app/layout.tsx +++ b/core/app/layout.tsx @@ -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 }) {children} - + + + diff --git a/core/app/page.tsx b/core/app/page.tsx index 17383fa89f..b9443695d5 100644 --- a/core/app/page.tsx +++ b/core/app/page.tsx @@ -1,14 +1,27 @@ 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>; +}) { + 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(); @@ -16,8 +29,8 @@ export default async function Page() { 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()); } diff --git a/core/lib/authentication/actions.ts b/core/lib/authentication/actions.ts index e7fbcab692..4070de9c23 100644 --- a/core/lib/authentication/actions.ts +++ b/core/lib/authentication/actions.ts @@ -101,10 +101,16 @@ export const loginWithPassword = defineServerAction(async function loginWithPass }; } // lucia authentication - const session = await lucia.createSession(user.id, { type: AuthTokenType.generic }); + const tokenType = user.isVerified ? AuthTokenType.generic : AuthTokenType.verifyEmail; + const session = await lucia.createSession(user.id, { type: tokenType }); const sessionCookie = lucia.createSessionCookie(session.id); (await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); + if (!user.isVerified) { + const newUrl = props.redirectTo ? `/verify?redirectTo=${props.redirectTo}` : "/verify"; + redirect(newUrl); + } + if (props.redirectTo && /^\/\w+/.test(props.redirectTo)) { redirect(props.redirectTo); } @@ -160,6 +166,48 @@ export const sendForgotPasswordMail = defineServerAction( } ); +const _sendVerifyEmailMail = async (props: { email: string; redirectTo?: string }) => { + const user = await getUserWithPasswordHash({ email: props.email }); + + if (!user) { + return { + success: true, + report: "Email verification email sent!", + }; + } + + // Invalidate any previous tokens + await invalidateTokensForUser(user.id, [AuthTokenType.generic]); + + const result = await Email.verifyEmail( + { + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + }, + props.redirectTo + ).send(); + + if ("error" in result) { + return { + error: result.error, + }; + } + + return { + success: true, + report: result.report ?? "Email verification email sent!", + }; +}; + +export const sendVerifyEmailMail = defineServerAction(async function sendVerifyEmailMail(props: { + email: string; + redirectTo?: string; +}) { + return _sendVerifyEmailMail(props); +}); + const newPasswordSchema = z.object({ password: z.string().min(8), }); @@ -339,6 +387,7 @@ export const publicSignup = defineServerAction(async function signup(props: { props.slug ?? generateUserSlug({ firstName: props.firstName, lastName: props.lastName }), passwordHash: await createPasswordHash(props.password), + isVerified: false, }, trx ).executeTakeFirstOrThrow((err) => { @@ -348,7 +397,6 @@ export const publicSignup = defineServerAction(async function signup(props: { ); }); - // TODO: add to community const newMember = await insertCommunityMember( { userId: newUser.id, @@ -358,8 +406,7 @@ export const publicSignup = defineServerAction(async function signup(props: { trx ).executeTakeFirstOrThrow(); - // TODO: send verification email - return { ...newUser, needsVerification: false }; + return { ...newUser, needsVerification: true }; } catch (e) { if (isUniqueConstraintError(e) && e.table === "users") { return SignupErrors.EMAIL_ALREADY_EXISTS({ email: props.email }); @@ -374,18 +421,19 @@ export const publicSignup = defineServerAction(async function signup(props: { return newUser; } - if ("needsVerification" in newUser && newUser.needsVerification) { - return { - success: true, - report: "Please check your email to verify your account!", - needsVerification: true, - }; + const verifyEmailResult = await _sendVerifyEmailMail({ + email: newUser.email, + redirectTo: props.redirectTo, + }); + + if (verifyEmailResult.error) { + return verifyEmailResult; } - // log them in + // log them in with a session that requires email verification // lucia authentication - const newSession = await lucia.createSession(newUser.id, { type: AuthTokenType.generic }); + const newSession = await lucia.createSession(newUser.id, { type: AuthTokenType.verifyEmail }); const newSessionCookie = lucia.createSessionCookie(newSession.id); (await cookies()).set( newSessionCookie.name, @@ -393,14 +441,8 @@ export const publicSignup = defineServerAction(async function signup(props: { newSessionCookie.attributes ); - if (props.redirectTo) { - redirect(props.redirectTo); - } - - await redirectUser(); - - // typescript cannot sense Promise not returning - return "" as never; + const newUrl = props.redirectTo ? `/verify?redirectTo=${props.redirectTo}` : "/verify"; + redirect(newUrl); }); /** @@ -449,12 +491,16 @@ export const legacySignup = defineServerAction(async function signup(props: { const trx = db.transaction(); const updatedUser = await trx.execute(async (trx) => { + const changedEmail = user.email !== props.email; const updatedUser = await updateUser( { id: props.id, firstName: props.firstName, lastName: props.lastName, email: props.email, + // If the user changed the email that they signed up with, make + // sure they are not verified (magic-link login will mark them as verified) + ...(changedEmail ? { isVerified: false } : {}), }, trx ); @@ -467,9 +513,8 @@ export const legacySignup = defineServerAction(async function signup(props: { trx ); - if (updatedUser.email !== user.email) { + if (changedEmail) { return { ...updatedUser, needsVerification: true }; - // TODO: send email verification } return { @@ -478,12 +523,18 @@ export const legacySignup = defineServerAction(async function signup(props: { }; }); + let sessionType = AuthTokenType.generic; if ("needsVerification" in updatedUser && updatedUser.needsVerification) { - return { - success: true, - report: "Please check your email to verify your account!", - needsVerification: true, - }; + const verifyEmailResult = await _sendVerifyEmailMail({ + email: updatedUser.email, + redirectTo: props.redirect ?? undefined, + }); + + if (verifyEmailResult.error) { + return verifyEmailResult; + } + + sessionType = AuthTokenType.verifyEmail; } // invalidate sessions and tokens @@ -493,7 +544,7 @@ export const legacySignup = defineServerAction(async function signup(props: { ]); // log them in - const newSession = await lucia.createSession(updatedUser.id, { type: AuthTokenType.generic }); + const newSession = await lucia.createSession(updatedUser.id, { type: sessionType }); const newSessionCookie = lucia.createSessionCookie(newSession.id); (await cookies()).set( newSessionCookie.name, diff --git a/core/lib/authentication/loginData.ts b/core/lib/authentication/loginData.ts index 57261ce849..ea1576069f 100644 --- a/core/lib/authentication/loginData.ts +++ b/core/lib/authentication/loginData.ts @@ -4,6 +4,8 @@ import { cache } from "react"; import { redirect } from "next/navigation"; import { getPathname } from "@nimpl/getters/get-pathname"; +import { AuthTokenType } from "db/public"; + import type { ExtraSessionValidationOptions } from "./lucia"; import { validateRequest } from "./lucia"; @@ -11,14 +13,21 @@ export const getLoginData = cache(async (opts?: ExtraSessionValidationOptions) = return validateRequest(opts); }); -export const getPageLoginData = cache(async (opts?: ExtraSessionValidationOptions) => { - const loginData = await getLoginData(opts); +export const getPageLoginData = cache(async () => { + const loginData = await getLoginData({ + allowedSessions: [AuthTokenType.generic, AuthTokenType.verifyEmail], + }); if (!loginData.user) { const pathname = getPathname(); redirect(pathname ? `/login?redirectTo=${encodeURIComponent(pathname)}` : "/login"); } + if (loginData.session && loginData.session.type === AuthTokenType.verifyEmail) { + const pathname = getPathname(); + redirect(pathname ? `/verify?redirectTo=${encodeURIComponent(pathname)}` : "/verify"); + } + return loginData; }); diff --git a/core/lib/authentication/lucia.ts b/core/lib/authentication/lucia.ts index b5410c8d7c..81b606a455 100644 --- a/core/lib/authentication/lucia.ts +++ b/core/lib/authentication/lucia.ts @@ -199,6 +199,7 @@ export const lucia = new Lucia(adapter, { memberships, avatar, orcid, + isVerified, }) => { return { email, @@ -211,6 +212,7 @@ export const lucia = new Lucia(adapter, { memberships, avatar, orcid, + isVerified, }; }, }); diff --git a/core/lib/redirect.ts b/core/lib/redirect.ts new file mode 100644 index 0000000000..76999f99df --- /dev/null +++ b/core/lib/redirect.ts @@ -0,0 +1,26 @@ +import { env } from "~/lib/env/env.mjs"; + +export const createRedirectUrl = (redirectTo: string, searchParams?: Record) => { + // it's a full url, just redirect them there + if (URL.canParse(redirectTo)) { + const url = new URL(redirectTo); + Object.entries(searchParams ?? {}).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + + return url; + } + + if (URL.canParse(redirectTo, env.PUBPUB_URL)) { + const url = new URL(redirectTo, env.PUBPUB_URL); + + Object.entries(searchParams ?? {}).forEach(([key, value]) => { + url.searchParams.append(key, value); + }); + + return url; + } + + // invalid redirectTo, redirect to not-found + return new URL(`/not-found?from=${encodeURIComponent(redirectTo)}`, env.PUBPUB_URL); +}; diff --git a/core/lib/server/email.tsx b/core/lib/server/email.tsx index b70e61bc97..55176a8d2b 100644 --- a/core/lib/server/email.tsx +++ b/core/lib/server/email.tsx @@ -1,7 +1,7 @@ import type { SendMailOptions } from "nodemailer"; import { render } from "@react-email/render"; -import { PasswordReset, RequestLinkToForm, SignupInvite } from "emails"; +import { PasswordReset, RequestLinkToForm, SignupInvite, VerifyEmail } from "emails"; import type { Communities, MemberRole, MembershipType, Users } from "db/public"; import { AuthTokenType } from "db/public"; @@ -16,6 +16,7 @@ import { createFormInviteLink } from "./form"; import { getSmtpClient } from "./mailgun"; const FIFTEEN_MINUTES = 1000 * 60 * 15; +const TWO_HOURS = 2 * 60 * 60 * 1000; type RequiredOptions = Required> & XOR<{ html: string }, { text: string }>; @@ -72,10 +73,7 @@ async function send( data: {}, }; } catch (error) { - logger.error({ - msg: `Failed to send email`, - error: error.message, - }); + logger.error({ msg: "Failed to send email", err: error }); return { error: error.message, }; @@ -113,6 +111,40 @@ export function passwordReset( }); } +export function verifyEmail( + user: Pick, + redirectTo?: string, + trx = db +) { + return buildSend(async () => { + const magicLink = await createMagicLink( + { + /** + * We use 'generic' here because they should be able to sign into their account + * once the link is clicked. Before this, they likely have the AuthTokenType.verifyEmail session + */ + type: AuthTokenType.generic, + expiresAt: new Date(Date.now() + TWO_HOURS), + path: redirectTo + ? `/verify?redirectTo=${encodeURIComponent(redirectTo)}` + : "/verify", + userId: user.id, + }, + trx + ); + + const email = await render( + + ); + + return { + to: user.email, + html: email, + subject: "Verify your email", + }; + }); +} + function inviteToForm() { // TODO: } diff --git a/core/lib/server/user.ts b/core/lib/server/user.ts index 09d45f555f..838634fe0b 100644 --- a/core/lib/server/user.ts +++ b/core/lib/server/user.ts @@ -43,6 +43,7 @@ export const SAFE_USER_SELECT = [ "users.isSuperAdmin", "users.avatar", "users.orcid", + "users.isVerified", ] as const satisfies ReadonlyArray>; export const getUser = cache((userIdOrEmail: XOR<{ id: UsersId }, { email: string }>, trx = db) => { diff --git a/core/playwright/fixtures/password-reset-page.ts b/core/playwright/fixtures/password-reset-page.ts new file mode 100644 index 0000000000..e1312c7848 --- /dev/null +++ b/core/playwright/fixtures/password-reset-page.ts @@ -0,0 +1,41 @@ +import type { Page } from "@playwright/test"; + +import { inbucketClient } from "../helpers"; + +export class PasswordResetPage { + constructor(public readonly page: Page) {} + + async goTo() { + await this.page.goto(`/forgot`); + } + + async sendResetEmail(email: string) { + await this.page.goto("/forgot"); + await this.page.getByRole("textbox").click(); + await this.page.getByRole("textbox").fill(email); + await this.page.getByRole("button", { name: "Send reset email" }).click(); + await this.page.getByRole("button", { name: "Close" }).click(); + } + + async goToUrlFromEmail(email: string) { + const message = await ( + await inbucketClient.getMailbox(email.split("@")[0]) + ).getLatestMessage(); + + const url = message.message.body.text?.match(/(http:\/\/.*?reset)\s/)?.[1]; + await message.delete(); + + if (!url) { + throw new Error("No url found!"); + } + + await this.page.goto(url); + await this.page.waitForURL("/reset"); + } + + async setNewPassword(newPassword: string) { + await this.page.getByRole("textbox").click(); + await this.page.getByRole("textbox").fill(newPassword); + await this.page.getByRole("button", { name: "Set new password" }).click(); + } +} diff --git a/core/playwright/formAccess.spec.ts b/core/playwright/formAccess.spec.ts index db594655f0..eeb1c4510f 100644 --- a/core/playwright/formAccess.spec.ts +++ b/core/playwright/formAccess.spec.ts @@ -11,7 +11,7 @@ import { db } from "~/lib/__tests__/db"; import { createSeed } from "~/prisma/seed/createSeed"; import { seedCommunity } from "~/prisma/seed/seedCommunity"; import { LoginPage } from "./fixtures/login-page"; -import { PubFieldsOfEachType, waitForBaseCommunityPage } from "./helpers"; +import { inbucketClient, PubFieldsOfEachType, waitForBaseCommunityPage } from "./helpers"; test.describe.configure({ mode: "serial" }); @@ -251,7 +251,7 @@ test.describe("public signup cases", () => { }); test.describe("public forms", () => { - test("non-users are able to signup for communityies and fill out public forms", async ({ + test("non-users are able to signup for communities and fill out public forms", async ({ page, }) => { const fillUrl = `/c/${community.community.slug}/public/forms/${community.forms.Evaluation.slug}/fill`; @@ -272,7 +272,29 @@ test.describe("public forms", () => { await page.getByLabel("First Name").fill(faker.person.firstName()); await page.getByLabel("Last Name").fill(faker.person.lastName()); await page.getByRole("button", { name: "Sign up" }).click(); - await page.waitForURL(fillUrl, { timeout: 10_000 }); + }); + + await test.step("non-users can verify their emails", async () => { + await page.getByRole("heading", { name: "Verify your email" }).waitFor(); + const { message } = await ( + await inbucketClient.getMailbox(testEmail.split("@")[0]) + ).getLatestMessage(); + const url = message.body.html?.match(/a href="([^"]+)"/)?.[1]; + expect(url).toBeTruthy(); + + // Use the browser to decode the html entities in our URL + const decodedUrl = await page.evaluate((url) => { + const elem = document.createElement("div"); + elem.innerHTML = url; + return elem.textContent!; + }, url!); + + await page.goto(decodedUrl); + await page + .getByRole("status") + .getByText("Your email is now verified", { exact: true }) + .waitFor(); + await page.waitForURL(fillUrl, { timeout: 5_000 }); }); let pubId: PubsId; @@ -281,7 +303,7 @@ test.describe("public forms", () => { await page.getByLabel("Content").fill("Test Content"); await page.getByRole("button", { name: "Submit" }).click(); const submissionMessage = await page.getByText("Go see you").textContent({ - timeout: 1_000, + timeout: 2_000, }); pubId = new URL(page.url()).searchParams.get("pubId") as PubsId; }); diff --git a/core/playwright/helpers.ts b/core/playwright/helpers.ts index 8df29d0e24..6b2bbd2703 100644 --- a/core/playwright/helpers.ts +++ b/core/playwright/helpers.ts @@ -5,6 +5,7 @@ import { faker } from "@faker-js/faker"; import { CoreSchemaType, MemberRole } from "db/public"; +import type { MessageResponse } from "./inbucketClient"; import type { CommunitySeedOutput, Seed } from "~/prisma/seed/createSeed"; import { createSeed } from "~/prisma/seed/createSeed"; import { seedCommunity } from "~/prisma/seed/seedCommunity"; @@ -49,6 +50,22 @@ const INBUCKET_TESTING_URL = process.env.INBUCKET_URL ?? "http://localhost:54324 export const inbucketClient = new InbucketClient(INBUCKET_TESTING_URL); +export const getUrlFromInbucketMessage = async (message: MessageResponse, page: Page) => { + const url = message.body.html?.match(/a href="([^"]+)"/)?.[1]; + if (!url) { + return undefined; + } + + // Use the browser to decode the html entities in our URL + const decodedUrl = await page.evaluate((url) => { + const elem = document.createElement("div"); + elem.innerHTML = url; + return elem.textContent!; + }, url!); + + return decodedUrl; +}; + export const retryAction = async (action: () => Promise, maxAttempts = 3) => { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { diff --git a/core/playwright/inbucketClient.ts b/core/playwright/inbucketClient.ts index 78ced458f8..c8aae30c85 100644 --- a/core/playwright/inbucketClient.ts +++ b/core/playwright/inbucketClient.ts @@ -9,7 +9,7 @@ type MailboxMessage = { type MailboxResponse = MailboxMessage[]; -type MessageResponse = { +export type MessageResponse = { mailbox: string; id: string; from: string; diff --git a/core/playwright/login.spec.ts b/core/playwright/login.spec.ts index 17d2688c59..a0cfae7545 100644 --- a/core/playwright/login.spec.ts +++ b/core/playwright/login.spec.ts @@ -7,6 +7,7 @@ import type { CommunitySeedOutput } from "~/prisma/seed/createSeed"; import { createSeed } from "~/prisma/seed/createSeed"; import { seedCommunity } from "~/prisma/seed/seedCommunity"; import { LoginPage } from "./fixtures/login-page"; +import { PasswordResetPage } from "./fixtures/password-reset-page"; import { inbucketClient } from "./helpers"; test.describe.configure({ mode: "serial" }); @@ -106,30 +107,13 @@ test.describe("Auth with lucia", () => { test("Password reset flow for lucia user", async ({ page }) => { // through forgot form - await page.goto("/forgot"); - await page.getByRole("textbox").click(); - await page.getByRole("textbox").fill(community.users["user3"].email); - await page.getByRole("button", { name: "Send reset email" }).click(); - await page.getByRole("button", { name: "Close" }).click(); - - const message = await ( - await inbucketClient.getMailbox(community.users["user3"].email.split("@")[0]) - ).getLatestMessage(); - - const url = message.message.body.text?.match(/(http:\/\/.*?reset)\s/)?.[1]; - await message.delete(); - - if (!url) { - throw new Error("No url found!"); - } - - await page.goto(url); - - await page.waitForURL("/reset"); - await page.getByRole("textbox").click(); - await page.getByRole("textbox").fill("some-pubpub"); - await page.getByRole("button", { name: "Set new password" }).click(); - + const passwordResetPage = new PasswordResetPage(page); + await passwordResetPage.goTo(); + const email = community.users["user3"].email; + const newPassword = "some-pubpub"; + await passwordResetPage.sendResetEmail(email); + await passwordResetPage.goToUrlFromEmail(email); + await passwordResetPage.setNewPassword(newPassword); await page.waitForURL("/login"); // through settings diff --git a/core/playwright/verifyEmail.spec.ts b/core/playwright/verifyEmail.spec.ts new file mode 100644 index 0000000000..5357d69727 --- /dev/null +++ b/core/playwright/verifyEmail.spec.ts @@ -0,0 +1,210 @@ +import type { Page } from "@playwright/test"; + +import { expect, test } from "@playwright/test"; + +import type { UsersId } from "db/public"; +import { CoreSchemaType, MemberRole } from "db/public"; + +import type { CommunitySeedOutput } from "~/prisma/seed/createSeed"; +import { createSeed } from "~/prisma/seed/createSeed"; +import { seedCommunity } from "~/prisma/seed/seedCommunity"; +import { LoginPage } from "./fixtures/login-page"; +import { PasswordResetPage } from "./fixtures/password-reset-page"; +import { getUrlFromInbucketMessage, inbucketClient, PubFieldsOfEachType } from "./helpers"; + +test.describe.configure({ mode: "serial" }); + +let page: Page; + +const communitySlug = `test-community-${new Date().getTime()}`; + +const password = "password"; +const seed = createSeed({ + community: { + name: "test community", + slug: communitySlug, + }, + pubFields: { + Title: { + schemaName: CoreSchemaType.String, + }, + Content: { + schemaName: CoreSchemaType.String, + }, + ...PubFieldsOfEachType, + }, + users: { + admin: { + role: MemberRole.admin, + password, + }, + unverifiedJim: { + role: MemberRole.admin, + password, + isVerified: false, + }, + unverifiedJoe: { + role: MemberRole.admin, + password, + isVerified: false, + }, + unverifiedBob: { + role: MemberRole.admin, + password, + isVerified: false, + }, + baseMember: { + role: MemberRole.contributor, + password, + }, + }, + pubTypes: { + Submission: { + Title: { isTitle: true }, + Content: { isTitle: false }, + }, + }, + stages: { + Evaluating: {}, + }, + pubs: [ + { + pubType: "Submission", + values: { + Title: "The Activity of Snails", + }, + stage: "Evaluating", + }, + ], +}); + +let community: CommunitySeedOutput; + +test.beforeAll(async ({ browser }) => { + community = await seedCommunity(seed); + + page = await browser.newPage(); +}); + +test.describe("unverified user", () => { + test("cannot see other parts of the app", async ({ page }) => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.login(community.users.unverifiedJim.email, password); + await page.waitForURL(`/verify`); + + const inaccessiblePages = [`/c/${community.community.slug}/stages`, "/communities"]; + for (const p of inaccessiblePages) { + await page.goto(p); + await page.waitForURL(`/verify?redirectTo=${encodeURIComponent(p)}`); + } + }); + + test("can login and request another verification code", async ({ page }) => { + await test.step("login", async () => { + const loginPage = new LoginPage(page); + await loginPage.goto(); + await loginPage.login(community.users.unverifiedJim.email, password); + await page.waitForURL(`/verify`); + }); + + const firstVerification = await test.step("request another verification code", async () => { + await page.getByRole("button", { name: "Resend verification email" }).click(); + await page.getByRole("button", { name: "Success" }).waitFor(); + const result = await ( + await inbucketClient.getMailbox(community.users.unverifiedJim.email.split("@")[0]) + ).getLatestMessage(); + const url = await getUrlFromInbucketMessage(result.message, page); + expect(url).toBeTruthy(); + result.delete(); + return url; + }); + + const secondVerification = + await test.step("request yet another verification code to invalidate the first", async () => { + await page.getByRole("button", { name: "Success" }).click(); + // Wait so that the email gets a chance to send + await page.waitForTimeout(1_000); + const { message } = await ( + await inbucketClient.getMailbox( + community.users.unverifiedJim.email.split("@")[0] + ) + ).getLatestMessage(); + const url = await getUrlFromInbucketMessage(message, page); + expect(url).toBeTruthy(); + return url; + }); + + expect(firstVerification).not.toEqual(secondVerification); + + await test.step("the first verification token should fail", async () => { + await page.goto(firstVerification!); + await page.getByText("Your token has expired.").waitFor(); + }); + + await test.step("second verification should succeed", async () => { + await page.goto(secondVerification!); + await page + .getByRole("status") + .getByText("Your email is now verified", { exact: true }) + .waitFor(); + }); + }); + + test("redirected to /verify page with redirect after signin", async ({ page }) => { + const loginPage = new LoginPage(page); + const redirect = "?redirectTo=/communities"; + // Manually go to a page with a redirect url + await page.goto(`/login${redirect}`); + await loginPage.login(community.users.unverifiedJoe.email, password); + await page.getByText("Verify your email", { exact: true }).waitFor(); + await page.waitForURL(`/verify${redirect}`); + }); + + test("redirect url carries through after signing in and requesting a new link", async ({ + page, + }) => { + const redirect = "?redirectTo=/communities"; + await test.step("login with redirect", async () => { + const loginPage = new LoginPage(page); + await page.goto(`/login${redirect}`); + await loginPage.login(community.users.unverifiedJoe.email, password); + await page.getByText("Verify your email", { exact: true }).waitFor(); + await page.waitForURL(`/verify${redirect}`); + }); + + const url = await test.step("request a verification code", async () => { + await page.getByRole("button", { name: "Resend verification email" }).click(); + await page.getByRole("button", { name: "Success" }).waitFor(); + const { message } = await ( + await inbucketClient.getMailbox(community.users.unverifiedJoe.email.split("@")[0]) + ).getLatestMessage(); + const url = await getUrlFromInbucketMessage(message, page); + expect(url).toBeTruthy(); + return url as string; + }); + + await test.step("link in email redirects to redirect link", async () => { + await page.goto(url); + await page.waitForURL("/communities**"); + await page + .getByRole("status") + .getByText("Your email is now verified", { exact: true }) + .waitFor(); + }); + }); + + test("going thru forget password flow verifies the user", async ({ page }) => { + const passwordResetPage = new PasswordResetPage(page); + await passwordResetPage.goTo(); + const email = community.users.unverifiedBob.email; + const newPassword = "new-password"; + await passwordResetPage.sendResetEmail(email); + await passwordResetPage.goToUrlFromEmail(email); + await passwordResetPage.setNewPassword(newPassword); + await page.waitForURL("/login"); + const loginPage = new LoginPage(page); + // Can now log in and be directed to the base community page, not verify + await loginPage.loginAndWaitForNavigation(email, newPassword); + }); +}); diff --git a/core/prisma/migrations/20250407203301_add_is_verified_to_user/migration.sql b/core/prisma/migrations/20250407203301_add_is_verified_to_user/migration.sql new file mode 100644 index 0000000000..a945ea48ff --- /dev/null +++ b/core/prisma/migrations/20250407203301_add_is_verified_to_user/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable + +ALTER TABLE "users" ADD COLUMN "isVerified" BOOLEAN NOT NULL DEFAULT false; + +-- Set existing users to verified +UPDATE "users" SET "isVerified" = true; \ No newline at end of file diff --git a/core/prisma/schema/schema.dbml b/core/prisma/schema/schema.dbml index ce0de98d03..5e1fd95a0d 100644 --- a/core/prisma/schema/schema.dbml +++ b/core/prisma/schema/schema.dbml @@ -30,6 +30,7 @@ Table users { updatedAt DateTime [default: `now()`, not null] isSuperAdmin Boolean [not null, default: false] passwordHash String + isVerified Boolean [not null, default: false] memberGroups member_groups [not null] AuthToken auth_tokens [not null] assignedPubs pubs [not null] diff --git a/core/prisma/schema/schema.prisma b/core/prisma/schema/schema.prisma index bf3eb7f105..0d00294694 100644 --- a/core/prisma/schema/schema.prisma +++ b/core/prisma/schema/schema.prisma @@ -28,6 +28,7 @@ model User { updatedAt DateTime @default(now()) @updatedAt isSuperAdmin Boolean @default(false) passwordHash String? + isVerified Boolean @default(false) memberGroups MemberGroup[] AuthToken AuthToken[] diff --git a/core/prisma/seed.ts b/core/prisma/seed.ts index cf74b3dcc9..9030832985 100644 --- a/core/prisma/seed.ts +++ b/core/prisma/seed.ts @@ -21,6 +21,7 @@ async function createUserMembers({ isSuperAdmin, role, prismaCommunityIds, + isVerified, }: { email: string; password: string; @@ -30,6 +31,7 @@ async function createUserMembers({ isSuperAdmin: boolean; role: MemberRole; prismaCommunityIds: string[]; + isVerified: boolean; }) { const values = { slug, @@ -39,6 +41,7 @@ async function createUserMembers({ passwordHash: await createPasswordHash(password), avatar: "/demo/person.png", isSuperAdmin, + isVerified, }; const memberships = prismaCommunityIds.map((id) => ({ @@ -98,6 +101,7 @@ async function main() { isSuperAdmin: true, role: MemberRole.admin, prismaCommunityIds, + isVerified: true, }), createUserMembers({ @@ -109,6 +113,7 @@ async function main() { isSuperAdmin: false, role: MemberRole.editor, prismaCommunityIds, + isVerified: true, }), createUserMembers({ @@ -120,6 +125,7 @@ async function main() { isSuperAdmin: false, role: MemberRole.contributor, prismaCommunityIds, + isVerified: true, }), ]); } diff --git a/core/prisma/seed/seedCommunity.ts b/core/prisma/seed/seedCommunity.ts index 2f8c272fa1..bf9d4494c9 100644 --- a/core/prisma/seed/seedCommunity.ts +++ b/core/prisma/seed/seedCommunity.ts @@ -95,6 +95,10 @@ export type UsersInitializer = Record< isSuperAdmin?: boolean; slug?: string; existing?: false; + /** + * @default true + */ + isVerified?: boolean; } | { id: UsersId; @@ -883,6 +887,7 @@ export async function seedCommunity< avatar: userInfo.avatar ?? faker.image.avatar(), passwordHash: await createPasswordHash(userInfo.password ?? faker.internet.password()), isSuperAdmin: userInfo.isSuperAdmin ?? false, + isVerified: userInfo.isVerified === false ? false : true, // the key of the user initializer })) ); diff --git a/packages/db/src/public/Users.ts b/packages/db/src/public/Users.ts index 9ccbe0abda..ca4a98d57d 100644 --- a/packages/db/src/public/Users.ts +++ b/packages/db/src/public/Users.ts @@ -31,6 +31,8 @@ export interface UsersTable { isSuperAdmin: ColumnType; passwordHash: ColumnType; + + isVerified: ColumnType; } export type Users = Selectable; @@ -53,6 +55,7 @@ export const usersSchema = z.object({ orcid: z.string().nullable(), isSuperAdmin: z.boolean(), passwordHash: z.string().nullable(), + isVerified: z.boolean(), }); export const usersInitializerSchema = z.object({ @@ -67,6 +70,7 @@ export const usersInitializerSchema = z.object({ orcid: z.string().optional().nullable(), isSuperAdmin: z.boolean().optional(), passwordHash: z.string().optional().nullable(), + isVerified: z.boolean().optional(), }); export const usersMutatorSchema = z.object({ @@ -81,4 +85,5 @@ export const usersMutatorSchema = z.object({ orcid: z.string().optional().nullable(), isSuperAdmin: z.boolean().optional(), passwordHash: z.string().optional().nullable(), + isVerified: z.boolean().optional(), }); diff --git a/packages/db/src/table-names.ts b/packages/db/src/table-names.ts index bcc7fb164e..82cb0ebf8d 100644 --- a/packages/db/src/table-names.ts +++ b/packages/db/src/table-names.ts @@ -1992,6 +1992,14 @@ export const databaseTables = [ isAutoIncrementing: false, hasDefaultValue: false, }, + { + name: "isVerified", + dataType: "bool", + dataTypeSchema: "pg_catalog", + isNullable: false, + isAutoIncrementing: false, + hasDefaultValue: true, + }, ], }, ]; diff --git a/packages/emails/src/index.tsx b/packages/emails/src/index.tsx index 16bf3e7ccd..c0a28fe079 100644 --- a/packages/emails/src/index.tsx +++ b/packages/emails/src/index.tsx @@ -1,3 +1,4 @@ export * from "./password-reset"; export * from "./signup-invite"; export * from "./request-link-to-form"; +export * from "./verify-email"; diff --git a/packages/emails/src/verify-email.tsx b/packages/emails/src/verify-email.tsx new file mode 100644 index 0000000000..20a42319f5 --- /dev/null +++ b/packages/emails/src/verify-email.tsx @@ -0,0 +1,82 @@ +import * as React from "react"; +import { + Body, + Button, + Container, + Head, + Heading, + Hr, + Html, + Img, + Link, + Preview, + Section, + Tailwind, + Text, +} from "@react-email/components"; + +interface VerifyEmailprops { + firstName: string; + verifyEmailLink: string; + previewText?: string; +} + +export const VerifyEmail = ({ + firstName, + verifyEmailLink, + previewText = `Verify your email`, +}: VerifyEmailprops) => { + const baseUrl = process.env.PUBPUB_URL ? process.env.PUBPUB_URL : ""; + + return ( + + + {previewText} + + + +
+ PubPub +
+ + Verify your email + + + Hello {firstName}, + + + You can use the button below to verify your email. If you were not + expecting this invitation, please reply to this email to get in touch + with us. + +
+ +
+ + or copy and paste this URL into your browser:{" "} + + {verifyEmailLink} + + +
+ + If you were not expecting this invitation, please reply to this email to + get in touch with us. + +
+ +
+ + ); +}; diff --git a/packages/ui/src/toast.tsx b/packages/ui/src/toast.tsx index b3d1cea2e5..a10288a79e 100644 --- a/packages/ui/src/toast.tsx +++ b/packages/ui/src/toast.tsx @@ -34,6 +34,7 @@ const toastVariants = cva( default: "border bg-background text-foreground", destructive: "destructive group border-destructive bg-destructive text-destructive-foreground", + success: "rounded border-emerald-100 bg-emerald-50", }, }, defaultVariants: { diff --git a/packages/ui/src/use-toast.tsx b/packages/ui/src/use-toast.tsx index 6ebafdbb0e..54a3fe0b8d 100644 --- a/packages/ui/src/use-toast.tsx +++ b/packages/ui/src/use-toast.tsx @@ -8,7 +8,7 @@ import type { ToastActionElement, ToastProps } from "./toast"; const TOAST_LIMIT = 1; const TOAST_REMOVE_DELAY = 1000000; -type ToasterToast = ToastProps & { +export type ToasterToast = ToastProps & { id: string; title?: React.ReactNode; description?: React.ReactNode; @@ -136,7 +136,6 @@ function dispatch(action: Action) { listener(memoryState); }); } - type Toast = Omit; function toast({ ...props }: Toast) {