Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
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
37 changes: 12 additions & 25 deletions core/app/(user)/magic-link/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand All @@ -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);
Copy link
Copy Markdown
Contributor Author

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

return NextResponse.redirect(url, opts);
};

/**
Expand Down Expand Up @@ -129,6 +108,14 @@ export async function GET(req: NextRequest) {

const { user: tokenUser, authTokenType } = tokenSettled.value;

if (!tokenUser.isVerified) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

by putting this here in the magic-link route, we automatically mark users who go through this route as verified since they would've come from their email. this lets us get the requirement "Forgot password also functions as verification, in case where user signs up, never completes verification, and deletes verification email" for free

await db
.updateTable("users")
.set({ isVerified: true })
.where("id", "=", tokenUser.id)
.execute();
}

const session = await lucia.createSession(tokenUser.id, {
type: authTokenType,
});
Expand Down
38 changes: 38 additions & 0 deletions core/app/(user)/verify/ResendVerificationButton.tsx
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
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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..."
/>
);
};
52 changes: 52 additions & 0 deletions core/app/(user)/verify/page.tsx
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>
);
}
53 changes: 53 additions & 0 deletions core/app/RootToaster.tsx
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 />;
};
2 changes: 0 additions & 2 deletions core/app/c/(public)/[communitySlug]/public/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 0 additions & 6 deletions core/app/components/Signup/BaseSignupForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,6 @@ export function BaseSignupForm(props: {
idleText="Finish sign up"
/>
</div>
{/* <div className="mt-4 text-center text-sm">
Already have an account?{" "}
<Link href="#" className="underline">
Sign in
</Link>
</div> */}
</CardContent>
<CardFooter>
Or{" "}
Expand Down
2 changes: 1 addition & 1 deletion core/app/components/SubmitButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions core/app/layout.tsx
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",
Expand All @@ -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
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 Suspense since RootToaster needs to useSearchParams in order to figure out whether to render the 'verified' toast

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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>
Expand Down
23 changes: 18 additions & 5 deletions core/app/page.tsx
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());
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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 ?verified=true could get passed along to arbitrary pages

}
Loading
Loading