mirror of
https://github.com/lukevella/rallly.git
synced 2025-06-06 20:51:48 +02:00
♻️ Switch to next-auth for handling authentication (#899)
This commit is contained in:
parent
5f9e428432
commit
6fa66da681
65 changed files with 1514 additions and 1586 deletions
|
@ -1,15 +1,9 @@
|
|||
import type { IncomingMessage, ServerResponse } from "http";
|
||||
import { getIronSession } from "iron-session";
|
||||
import { withIronSessionApiRoute, withIronSessionSsr } from "iron-session/next";
|
||||
import {
|
||||
GetServerSideProps,
|
||||
GetServerSidePropsContext,
|
||||
NextApiHandler,
|
||||
} from "next";
|
||||
import { withIronSessionApiRoute } from "iron-session/next";
|
||||
import { NextApiHandler } from "next";
|
||||
|
||||
import { sessionConfig } from "../session-config";
|
||||
import { createSSGHelperFromContext } from "../trpc/context";
|
||||
import { composeGetServerSideProps } from "./utils";
|
||||
|
||||
export function withSessionRoute(handler: NextApiHandler) {
|
||||
return withIronSessionApiRoute(handler, sessionConfig);
|
||||
|
@ -21,60 +15,3 @@ export const getSession = async (
|
|||
) => {
|
||||
return getIronSession(req, res, sessionConfig);
|
||||
};
|
||||
|
||||
export function withSessionSsr(
|
||||
handler: GetServerSideProps | GetServerSideProps[],
|
||||
options?: {
|
||||
onPrefetch?: (
|
||||
ssg: Awaited<ReturnType<typeof createSSGHelperFromContext>>,
|
||||
ctx: GetServerSidePropsContext,
|
||||
) => Promise<void>;
|
||||
},
|
||||
): GetServerSideProps {
|
||||
const composedHandler = Array.isArray(handler)
|
||||
? composeGetServerSideProps(...handler)
|
||||
: handler;
|
||||
|
||||
return withIronSessionSsr(async (ctx) => {
|
||||
const ssg = await createSSGHelperFromContext(ctx);
|
||||
await ssg.whoami.get.prefetch(); // always prefetch user
|
||||
if (options?.onPrefetch) {
|
||||
try {
|
||||
await options.onPrefetch(ssg, ctx);
|
||||
} catch {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
const res = await composedHandler(ctx);
|
||||
if ("props" in res) {
|
||||
return {
|
||||
...res,
|
||||
props: {
|
||||
...res.props,
|
||||
trpcState: ssg.dehydrate(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return res;
|
||||
}, sessionConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Require user to be logged in
|
||||
* @returns
|
||||
*/
|
||||
export const withAuth: GetServerSideProps = async (ctx) => {
|
||||
if (!ctx.req.session.user || ctx.req.session.user.isGuest) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/login",
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return { props: {} };
|
||||
};
|
||||
|
|
|
@ -1,12 +1,20 @@
|
|||
import { EmailClient } from "@rallly/emails";
|
||||
import * as trpcNext from "@trpc/server/adapters/next";
|
||||
|
||||
import { createContext } from "../../trpc/context";
|
||||
import { appRouter } from "../../trpc/routers";
|
||||
import { withSessionRoute } from "../session";
|
||||
|
||||
export const trpcNextApiHandler = withSessionRoute(
|
||||
trpcNext.createNextApiHandler({
|
||||
export interface TRPCContext {
|
||||
user: { id: string; isGuest: boolean };
|
||||
emailClient: EmailClient;
|
||||
isSelfHosted: boolean;
|
||||
isEmailBlocked?: (email: string) => boolean;
|
||||
}
|
||||
|
||||
export const trpcNextApiHandler = (context: TRPCContext) => {
|
||||
return trpcNext.createNextApiHandler({
|
||||
router: appRouter,
|
||||
createContext,
|
||||
}),
|
||||
);
|
||||
createContext: async () => {
|
||||
return context;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
import { TimeFormat } from "@rallly/database";
|
||||
import { sealData, unsealData } from "iron-session";
|
||||
|
||||
import { sessionConfig } from "./session-config";
|
||||
|
@ -6,11 +5,6 @@ import { sessionConfig } from "./session-config";
|
|||
type UserSessionData = {
|
||||
id: string;
|
||||
isGuest: boolean;
|
||||
preferences?: {
|
||||
timeZone?: string;
|
||||
weekStart?: number;
|
||||
timeFormat?: TimeFormat;
|
||||
};
|
||||
};
|
||||
|
||||
declare module "iron-session" {
|
||||
|
|
|
@ -1,62 +0,0 @@
|
|||
import { EmailClient, SupportedEmailProviders } from "@rallly/emails";
|
||||
import { createProxySSGHelpers } from "@trpc/react-query/ssg";
|
||||
import * as trpc from "@trpc/server";
|
||||
import * as trpcNext from "@trpc/server/adapters/next";
|
||||
import { GetServerSidePropsContext } from "next";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { randomid } from "../utils/nanoid";
|
||||
import { appRouter } from "./routers";
|
||||
|
||||
// Avoid use NODE_ENV directly because it will be replaced when using the dev server for e2e tests
|
||||
const env = process.env["NODE" + "_ENV"];
|
||||
|
||||
export async function createContext(
|
||||
opts: trpcNext.CreateNextContextOptions | GetServerSidePropsContext,
|
||||
) {
|
||||
let user = opts.req.session.user;
|
||||
if (!user) {
|
||||
user = {
|
||||
id: `user-${randomid()}`,
|
||||
isGuest: true,
|
||||
};
|
||||
opts.req.session.user = user;
|
||||
await opts.req.session.save();
|
||||
}
|
||||
|
||||
const emailClient = new EmailClient({
|
||||
openPreviews: env === "development",
|
||||
useTestServer: env === "test",
|
||||
provider: {
|
||||
name: (process.env.EMAIL_PROVIDER as SupportedEmailProviders) ?? "smtp",
|
||||
},
|
||||
mail: {
|
||||
from: {
|
||||
name: "Rallly",
|
||||
address:
|
||||
(process.env.NOREPLY_EMAIL as string) ||
|
||||
(process.env.SUPPORT_EMAIL as string),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user,
|
||||
session: opts.req.session,
|
||||
req: opts.req,
|
||||
res: opts.res,
|
||||
isSelfHosted: process.env.NEXT_PUBLIC_SELF_HOSTED === "true",
|
||||
emailClient,
|
||||
};
|
||||
}
|
||||
|
||||
export type Context = trpc.inferAsyncReturnType<typeof createContext>;
|
||||
|
||||
export const createSSGHelperFromContext = async (
|
||||
ctx: GetServerSidePropsContext,
|
||||
) =>
|
||||
createProxySSGHelpers({
|
||||
router: appRouter,
|
||||
ctx: await createContext(ctx),
|
||||
transformer: superjson,
|
||||
});
|
|
@ -1,12 +1,10 @@
|
|||
import { prisma } from "@rallly/database";
|
||||
import { absoluteUrl } from "@rallly/utils";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createToken, decryptToken } from "../../session";
|
||||
import { generateOtp } from "../../utils/nanoid";
|
||||
import { publicProcedure, router } from "../trpc";
|
||||
import { LoginTokenPayload, RegistrationTokenPayload } from "../types";
|
||||
import { RegistrationTokenPayload } from "../types";
|
||||
|
||||
// assigns participants and comments created by guests to a user
|
||||
// we could have multiple guests because a login might be triggered from one device
|
||||
|
@ -46,27 +44,8 @@ const mergeGuestsIntoUser = async (userId: string, guestIds: string[]) => {
|
|||
});
|
||||
};
|
||||
|
||||
const isEmailBlocked = (email: string) => {
|
||||
if (process.env.ALLOWED_EMAILS) {
|
||||
const allowedEmails = process.env.ALLOWED_EMAILS.split(",");
|
||||
// Check whether the email matches enough of the patterns specified in ALLOWED_EMAILS
|
||||
const isAllowed = allowedEmails.some((allowedEmail) => {
|
||||
const regex = new RegExp(
|
||||
`^${allowedEmail
|
||||
.replace(/[.+?^${}()|[\]\\]/g, "\\$&")
|
||||
.replaceAll(/[*]/g, ".*")}$`,
|
||||
);
|
||||
return regex.test(email);
|
||||
});
|
||||
|
||||
if (!isAllowed) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export const auth = router({
|
||||
// @deprecated
|
||||
requestRegistration: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
|
@ -82,7 +61,7 @@ export const auth = router({
|
|||
| { ok: true; token: string }
|
||||
| { ok: false; reason: "userAlreadyExists" | "emailNotAllowed" }
|
||||
> => {
|
||||
if (isEmailBlocked(input.email)) {
|
||||
if (ctx.isEmailBlocked?.(input.email)) {
|
||||
return { ok: false, reason: "emailNotAllowed" };
|
||||
}
|
||||
|
||||
|
@ -124,6 +103,8 @@ export const auth = router({
|
|||
z.object({
|
||||
token: z.string(),
|
||||
code: z.string(),
|
||||
timeZone: z.string().optional(),
|
||||
locale: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
|
@ -143,115 +124,17 @@ export const auth = router({
|
|||
data: {
|
||||
name,
|
||||
email,
|
||||
timeZone: input.timeZone,
|
||||
locale: input.locale,
|
||||
},
|
||||
});
|
||||
|
||||
if (ctx.session.user?.isGuest) {
|
||||
await mergeGuestsIntoUser(user.id, [ctx.session.user.id]);
|
||||
if (ctx.user.isGuest) {
|
||||
await mergeGuestsIntoUser(user.id, [ctx.user.id]);
|
||||
}
|
||||
|
||||
ctx.session.user = {
|
||||
isGuest: false,
|
||||
id: user.id,
|
||||
};
|
||||
|
||||
await ctx.session.save();
|
||||
|
||||
return { ok: true, user };
|
||||
}),
|
||||
requestLogin: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
email: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(
|
||||
async ({
|
||||
input,
|
||||
ctx,
|
||||
}): Promise<
|
||||
| { ok: true; token: string }
|
||||
| { ok: false; reason: "emailNotAllowed" | "userNotFound" }
|
||||
> => {
|
||||
if (isEmailBlocked(input.email)) {
|
||||
return { ok: false, reason: "emailNotAllowed" };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: {
|
||||
email: input.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return { ok: false, reason: "userNotFound" };
|
||||
}
|
||||
|
||||
const code = generateOtp();
|
||||
|
||||
const token = await createToken<LoginTokenPayload>({
|
||||
userId: user.id,
|
||||
code,
|
||||
});
|
||||
|
||||
await ctx.emailClient.sendTemplate("LoginEmail", {
|
||||
to: input.email,
|
||||
subject: `${code} is your 6-digit code`,
|
||||
props: {
|
||||
name: user.name,
|
||||
code,
|
||||
magicLink: absoluteUrl(`/auth/login?token=${token}`),
|
||||
},
|
||||
});
|
||||
|
||||
return { ok: true, token };
|
||||
},
|
||||
),
|
||||
authenticateLogin: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
token: z.string(),
|
||||
code: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const payload = await decryptToken<LoginTokenPayload>(input.token);
|
||||
|
||||
if (!payload) {
|
||||
return { user: null };
|
||||
}
|
||||
|
||||
const { userId, code } = payload;
|
||||
|
||||
if (input.code !== code) {
|
||||
return { user: null };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
select: { id: true, name: true, email: true },
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "The user doesn't exist anymore",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session.user?.isGuest) {
|
||||
await mergeGuestsIntoUser(user.id, [ctx.session.user.id]);
|
||||
}
|
||||
|
||||
ctx.session.user = {
|
||||
isGuest: false,
|
||||
id: user.id,
|
||||
};
|
||||
|
||||
await ctx.session.save();
|
||||
|
||||
return { user };
|
||||
}),
|
||||
getUserPermission: publicProcedure
|
||||
.input(z.object({ token: z.string() }))
|
||||
.query(async ({ input }) => {
|
||||
|
|
|
@ -3,11 +3,9 @@ import { auth } from "./auth";
|
|||
import { polls } from "./polls";
|
||||
import { user } from "./user";
|
||||
import { userPreferences } from "./user-preferences";
|
||||
import { whoami } from "./whoami";
|
||||
|
||||
export const appRouter = mergeRouters(
|
||||
router({
|
||||
whoami,
|
||||
auth,
|
||||
polls,
|
||||
user,
|
||||
|
|
|
@ -121,9 +121,7 @@ export const polls = router({
|
|||
},
|
||||
});
|
||||
|
||||
const pollLink = ctx.user.isGuest
|
||||
? absoluteUrl(`/admin/${adminToken}`)
|
||||
: absoluteUrl(`/poll/${pollId}`);
|
||||
const pollLink = absoluteUrl(`/poll/${pollId}`);
|
||||
|
||||
const participantLink = shortUrl(`/invite/${pollId}`);
|
||||
|
||||
|
@ -288,7 +286,7 @@ export const polls = router({
|
|||
});
|
||||
}
|
||||
|
||||
const res = await prisma.watcher.findFirst({
|
||||
const watcher = await prisma.watcher.findFirst({
|
||||
where: {
|
||||
pollId: input.pollId,
|
||||
userId: ctx.user.id,
|
||||
|
@ -298,18 +296,13 @@ export const polls = router({
|
|||
},
|
||||
});
|
||||
|
||||
if (!res) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Not watching this poll",
|
||||
if (watcher) {
|
||||
await prisma.watcher.delete({
|
||||
where: {
|
||||
id: watcher.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.watcher.delete({
|
||||
where: {
|
||||
id: res.id,
|
||||
},
|
||||
});
|
||||
}),
|
||||
getByAdminUrlId: possiblyPublicProcedure
|
||||
.input(
|
||||
|
@ -321,28 +314,6 @@ export const polls = router({
|
|||
const res = await prisma.poll.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
timeZone: true,
|
||||
title: true,
|
||||
location: true,
|
||||
description: true,
|
||||
createdAt: true,
|
||||
adminUrlId: true,
|
||||
participantUrlId: true,
|
||||
closed: true,
|
||||
legacy: true,
|
||||
demo: true,
|
||||
options: {
|
||||
orderBy: {
|
||||
start: "asc",
|
||||
},
|
||||
},
|
||||
user: true,
|
||||
deleted: true,
|
||||
watchers: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
adminUrlId: input.urlId,
|
||||
|
|
|
@ -6,13 +6,7 @@ import { publicProcedure, router } from "../trpc";
|
|||
export const userPreferences = router({
|
||||
get: publicProcedure.query(async ({ ctx }) => {
|
||||
if (ctx.user.isGuest) {
|
||||
return ctx.user.preferences
|
||||
? {
|
||||
timeZone: ctx.user.preferences.timeZone ?? null,
|
||||
timeFormat: ctx.user.preferences.timeFormat ?? null,
|
||||
weekStart: ctx.user.preferences.weekStart ?? null,
|
||||
}
|
||||
: null;
|
||||
return null;
|
||||
} else {
|
||||
return await prisma.userPreferences.findUnique({
|
||||
where: {
|
||||
|
@ -48,21 +42,11 @@ export const userPreferences = router({
|
|||
...input,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
ctx.session.user = {
|
||||
...ctx.user,
|
||||
preferences: { ...ctx.user.preferences, ...input },
|
||||
};
|
||||
await ctx.session.save();
|
||||
}
|
||||
}),
|
||||
delete: publicProcedure.mutation(async ({ ctx }) => {
|
||||
if (ctx.user.isGuest) {
|
||||
ctx.session.user = {
|
||||
...ctx.user,
|
||||
preferences: undefined,
|
||||
};
|
||||
await ctx.session.save();
|
||||
// delete guest preferences
|
||||
} else {
|
||||
await prisma.userPreferences.delete({
|
||||
where: {
|
||||
|
|
|
@ -48,4 +48,23 @@ export const user = router({
|
|||
},
|
||||
});
|
||||
}),
|
||||
updatePreferences: privateProcedure
|
||||
.input(
|
||||
z.object({
|
||||
locale: z.string().optional(),
|
||||
timeZone: z.string().optional(),
|
||||
weekStart: z.number().min(0).max(6).optional(),
|
||||
timeFormat: z.enum(["hours12", "hours24"]).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (ctx.user.isGuest === false) {
|
||||
await prisma.user.update({
|
||||
where: {
|
||||
id: ctx.user.id,
|
||||
},
|
||||
data: input,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -1,64 +0,0 @@
|
|||
import { prisma } from "@rallly/database";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import z from "zod";
|
||||
|
||||
import { decryptToken } from "../../session";
|
||||
import { publicProcedure, router } from "../trpc";
|
||||
import { LoginTokenPayload } from "../types";
|
||||
|
||||
export const whoami = router({
|
||||
get: publicProcedure.query(async ({ ctx }) => {
|
||||
if (ctx.user.isGuest) {
|
||||
return { isGuest: true as const, id: ctx.user.id };
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
where: { id: ctx.user.id },
|
||||
});
|
||||
|
||||
if (user === null) {
|
||||
ctx.session.destroy();
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
return { isGuest: false as const, ...user };
|
||||
}),
|
||||
destroy: publicProcedure.mutation(async ({ ctx }) => {
|
||||
ctx.session.destroy();
|
||||
}),
|
||||
authenticate: publicProcedure
|
||||
.input(z.object({ token: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const payload = await decryptToken<LoginTokenPayload>(input.token);
|
||||
|
||||
if (!payload) {
|
||||
// token is invalid or expired
|
||||
throw new TRPCError({ code: "PARSE_ERROR", message: "Invalid token" });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
},
|
||||
where: { id: payload.userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// user does not exist
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "User not found" });
|
||||
}
|
||||
|
||||
ctx.session.user = { id: user.id, isGuest: false };
|
||||
|
||||
await ctx.session.save();
|
||||
|
||||
return user;
|
||||
}),
|
||||
});
|
|
@ -1,10 +1,10 @@
|
|||
import { initTRPC, TRPCError } from "@trpc/server";
|
||||
import superjson from "superjson";
|
||||
|
||||
import { TRPCContext } from "../next/trpc/server";
|
||||
import { getSubscriptionStatus } from "../utils/auth";
|
||||
import { Context } from "./context";
|
||||
|
||||
const t = initTRPC.context<Context>().create({
|
||||
const t = initTRPC.context<TRPCContext>().create({
|
||||
transformer: superjson,
|
||||
errorFormatter({ shape }) {
|
||||
return shape;
|
||||
|
|
|
@ -1,11 +1,8 @@
|
|||
export type RegistrationTokenPayload = {
|
||||
name: string;
|
||||
email: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
export type LoginTokenPayload = {
|
||||
userId: string;
|
||||
locale?: string;
|
||||
timeZone?: string;
|
||||
code: string;
|
||||
};
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue