diff --git a/apps/web/__mocks__/better-auth.ts b/apps/web/__mocks__/better-auth.ts new file mode 100644 index 000000000..0bb2acf50 --- /dev/null +++ b/apps/web/__mocks__/better-auth.ts @@ -0,0 +1 @@ +export default jest.fn(); diff --git a/apps/web/__mocks__/next-auth.ts b/apps/web/__mocks__/next-auth.ts deleted file mode 100644 index 80c5df64e..000000000 --- a/apps/web/__mocks__/next-auth.ts +++ /dev/null @@ -1,20 +0,0 @@ -export class AuthError extends Error { - type: string; - constructor(type: string) { - super(type); - this.type = type; - } -} - -const NextAuth = () => ({ - auth: jest.fn(), - signIn: jest.fn(), - signOut: jest.fn(), - handlers: { - GET: jest.fn(), - POST: jest.fn(), - }, - AuthError: AuthError, -}); - -export default NextAuth; diff --git a/apps/web/app/(with-contexts)/(with-layout)/layout.tsx b/apps/web/app/(with-contexts)/(with-layout)/layout.tsx index 653b8fe3a..c9ab054c3 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/layout.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/layout.tsx @@ -1,5 +1,3 @@ -import { auth } from "@/auth"; -import { SessionProvider } from "next-auth/react"; import HomepageLayout from "./home-page-layout"; import { headers } from "next/headers"; import { getFullSiteSetup } from "@ui-lib/utils"; @@ -11,18 +9,11 @@ export default async function Layout({ children: React.ReactNode; }) { const address = await getAddressFromHeaders(headers); - const [siteInfo, session] = await Promise.all([ - getFullSiteSetup(address), - auth(), - ]); + const siteInfo = await getFullSiteSetup(address); if (!siteInfo) { return null; } - return ( - - {children} - - ); + return {children}; } diff --git a/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx b/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx index dd73adb41..53a094613 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/login/login-form.tsx @@ -11,17 +11,15 @@ import { Input, Section, Text1, - Text2, Link as PageLink, } from "@courselit/page-primitives"; -import { useContext, useState } from "react"; +import { useCallback, useContext, useEffect, useRef, useState } from "react"; import { FormEvent } from "react"; -import { signIn } from "next-auth/react"; import { Form, useToast } from "@courselit/components-library"; import { BTN_LOGIN, BTN_LOGIN_GET_CODE, - BTN_LOGIN_CODE_INTIMATION, + LOGIN_CODE_INTIMATION_MESSAGE, LOGIN_NO_CODE, BTN_LOGIN_NO_CODE, LOGIN_FORM_LABEL, @@ -37,6 +35,7 @@ import { checkPermission } from "@courselit/utils"; import { Profile } from "@courselit/common-models"; import { getUserProfile } from "../../helpers"; import { ADMIN_PERMISSIONS } from "@ui-config/constants"; +import { authClient } from "@/lib/auth-client"; export default function LoginForm({ redirectTo }: { redirectTo?: string }) { const { theme } = useContext(ThemeContext); @@ -49,112 +48,82 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) { const serverConfig = useContext(ServerConfigContext); const { executeRecaptcha } = useRecaptcha(); const address = useContext(AddressContext); + const codeInputRef = useRef(null); - const requestCode = async function (e: FormEvent) { - e.preventDefault(); - setLoading(true); - setError(""); + const validateRecaptcha = useCallback(async (): Promise => { + if (!serverConfig.recaptchaSiteKey) { + return true; + } - if (serverConfig.recaptchaSiteKey) { - if (!executeRecaptcha) { - toast({ - title: TOAST_TITLE_ERROR, - description: - "reCAPTCHA service not available. Please try again later.", - variant: "destructive", - }); - setLoading(false); - return; - } + if (!executeRecaptcha) { + toast({ + title: TOAST_TITLE_ERROR, + description: + "reCAPTCHA service not available. Please try again later.", + variant: "destructive", + }); + setLoading(false); + return false; + } - const recaptchaToken = await executeRecaptcha("login_code_request"); - if (!recaptchaToken) { - toast({ - title: TOAST_TITLE_ERROR, - description: - "reCAPTCHA validation failed. Please try again.", - variant: "destructive", - }); - setLoading(false); - return; - } - try { - const recaptchaVerificationResponse = await fetch( - "/api/recaptcha", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ token: recaptchaToken }), - }, - ); + const recaptchaToken = await executeRecaptcha("login_code_request"); + if (!recaptchaToken) { + toast({ + title: TOAST_TITLE_ERROR, + description: "reCAPTCHA validation failed. Please try again.", + variant: "destructive", + }); + setLoading(false); + return false; + } + try { + const recaptchaVerificationResponse = await fetch( + "/api/recaptcha", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token: recaptchaToken }), + }, + ); - const recaptchaData = - await recaptchaVerificationResponse.json(); + const recaptchaData = await recaptchaVerificationResponse.json(); - if ( - !recaptchaVerificationResponse.ok || - !recaptchaData.success || - (recaptchaData.score && recaptchaData.score < 0.5) - ) { - toast({ - title: TOAST_TITLE_ERROR, - description: `reCAPTCHA verification failed. ${recaptchaData.score ? `Score: ${recaptchaData.score.toFixed(2)}.` : ""} Please try again.`, - variant: "destructive", - }); - setLoading(false); - return; - } - } catch (err) { - console.error("Error during reCAPTCHA verification:", err); + if ( + !recaptchaVerificationResponse.ok || + !recaptchaData.success || + (recaptchaData.score && recaptchaData.score < 0.5) + ) { toast({ title: TOAST_TITLE_ERROR, - description: - "reCAPTCHA verification failed. Please try again.", + description: `reCAPTCHA verification failed. ${recaptchaData.score ? `Score: ${recaptchaData.score.toFixed(2)}.` : ""} Please try again.`, variant: "destructive", }); setLoading(false); - return; - } - } - - try { - const url = `/api/auth/code/generate?email=${encodeURIComponent( - email, - )}`; - const response = await fetch(url); - const resp = await response.json(); - if (response.ok) { - setShowCode(true); - } else { - toast({ - title: TOAST_TITLE_ERROR, - description: resp.error || "Failed to request code.", - variant: "destructive", - }); + return false; } } catch (err) { - console.error("Error during requestCode:", err); toast({ title: TOAST_TITLE_ERROR, - description: "An unexpected error occurred. Please try again.", + description: "reCAPTCHA verification failed. Please try again.", variant: "destructive", }); - } finally { setLoading(false); + return false; } - }; + + return true; + }, []); const signInUser = async function (e: FormEvent) { e.preventDefault(); try { setLoading(true); - const response = await signIn("credentials", { - email, - code, - redirect: false, + const { error } = await authClient.signIn.emailOtp({ + email: email.trim().toLowerCase(), + otp: code, }); - if (response?.error) { - setError(`Can't sign you in at this time`); + if (error) { + setError(`Can't sign you in at this time: ${error.message}`); } else { window.location.href = redirectTo || @@ -178,6 +147,37 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) { } }; + useEffect(() => { + if (showCode) { + codeInputRef.current?.focus(); + } + }, [showCode]); + + const requestCode = async function (e: FormEvent) { + e.preventDefault(); + setLoading(true); + setError(""); + + if (!validateRecaptcha()) { + return; + } + + try { + const { error } = await authClient.emailOtp.sendVerificationOtp({ + email: email.trim().toLowerCase(), + type: "sign-in", + }); + + if (error) { + setError(error.message as any); + } else { + setShowCode(true); + } + } finally { + setLoading(false); + } + }; + return (
@@ -204,7 +204,7 @@ export default function LoginForm({ redirectTo }: { redirectTo?: string }) {
- + -
- {/* */} - -
)} {showCode && (
- {BTN_LOGIN_CODE_INTIMATION}{" "} + {LOGIN_CODE_INTIMATION_MESSAGE}{" "} {email}
-
- -
+ + {/*
*/}
- {LOGIN_NO_CODE} - - + +
)} diff --git a/apps/web/app/(with-contexts)/(with-layout)/login/page.tsx b/apps/web/app/(with-contexts)/(with-layout)/login/page.tsx index f15df7db7..a55992fb4 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/login/page.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/login/page.tsx @@ -1,13 +1,18 @@ import { auth } from "@/auth"; import { redirect } from "next/navigation"; import LoginForm from "./login-form"; +import { headers } from "next/headers"; export default async function LoginPage({ searchParams, }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; }) { - const session = await auth(); + const headersList = await headers(); + const session = await auth.api.getSession({ + headers: headersList, + }); + const redirectTo = (await searchParams).redirect as string | undefined; if (session) { diff --git a/apps/web/app/(with-contexts)/(with-layout)/logout/layout.tsx b/apps/web/app/(with-contexts)/(with-layout)/logout/layout.tsx index d8ac9aa94..4c3949f73 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/logout/layout.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/logout/layout.tsx @@ -1,4 +1,5 @@ import { auth } from "@/auth"; +import { headers } from "next/headers"; import { redirect } from "next/navigation"; export default async function Layout({ @@ -6,7 +7,9 @@ export default async function Layout({ }: { children: React.ReactNode; }) { - const session = await auth(); + const session = await auth.api.getSession({ + headers: await headers(), + }); if (!session) { redirect("/"); } diff --git a/apps/web/app/(with-contexts)/(with-layout)/logout/page.tsx b/apps/web/app/(with-contexts)/(with-layout)/logout/page.tsx index 96489a909..3520c4ff2 100644 --- a/apps/web/app/(with-contexts)/(with-layout)/logout/page.tsx +++ b/apps/web/app/(with-contexts)/(with-layout)/logout/page.tsx @@ -1,46 +1,44 @@ "use client"; import { Section, Text1, Button } from "@courselit/page-primitives"; -import { - LOGGING_OUT, - LOGOUT, - LOGOUT_MESSAGE, - TOAST_TITLE_ERROR, - UNABLE_TO_LOGOUT, -} from "@ui-config/strings"; -import { useContext, useState } from "react"; +import { LOGOUT, LOGOUT_MESSAGE } from "@ui-config/strings"; +import { useContext } from "react"; import { ThemeContext } from "@components/contexts"; -import { toast } from "@/hooks/use-toast"; +import { authClient } from "@/lib/auth-client"; +import { useToast } from "@courselit/components-library"; export default function ClientSide() { - const [loading, setLoading] = useState(false); const { theme } = useContext(ThemeContext); + const { toast } = useToast(); const handleLogout = async () => { - setLoading(true); - const response = await fetch("/logout/server"); - if (response.ok) { - window.location.href = "/"; - } else { + const { error } = await authClient.signOut({ + fetchOptions: { + onSuccess: () => { + window.location.href = "/login"; + }, + }, + }); + + if (error) { toast({ - title: TOAST_TITLE_ERROR, - description: UNABLE_TO_LOGOUT, + title: "Error", + description: error?.message, variant: "destructive", }); } - setLoading(false); }; return (
- {LOGOUT_MESSAGE} - +
+ {LOGOUT_MESSAGE} +
+ +
+
); } diff --git a/apps/web/app/(with-contexts)/(with-layout)/logout/server/route.ts b/apps/web/app/(with-contexts)/(with-layout)/logout/server/route.ts deleted file mode 100644 index ea337e8a8..000000000 --- a/apps/web/app/(with-contexts)/(with-layout)/logout/server/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { signOut } from "@/auth"; - -export async function GET() { - await signOut(); - return Response.json({ message: "Logged out" }); -} diff --git a/apps/web/app/(with-contexts)/action.ts b/apps/web/app/(with-contexts)/action.ts index b1bee9b8f..f920453cb 100644 --- a/apps/web/app/(with-contexts)/action.ts +++ b/apps/web/app/(with-contexts)/action.ts @@ -5,15 +5,19 @@ import { getUser } from "@/graphql/users/logic"; import { Profile, User } from "@courselit/common-models"; import GQLContext from "@models/GQLContext"; import { Types } from "mongoose"; +import { headers } from "next/headers"; export async function getProfile(): Promise { - const session = await auth(); + const session = await auth.api.getSession({ + headers: await headers(), + }); if (!session) { return null; } + const domainId = (session?.session as any)?.domainId; const userId = (session?.user as any)?.userId; - const domainId = (session?.user as any)?.domain; + try { const user = await getUser(userId, { user: { diff --git a/apps/web/app/(with-contexts)/course/[slug]/[id]/layout.tsx b/apps/web/app/(with-contexts)/course/[slug]/[id]/layout.tsx index 0403c2e60..e5bc545ea 100644 --- a/apps/web/app/(with-contexts)/course/[slug]/[id]/layout.tsx +++ b/apps/web/app/(with-contexts)/course/[slug]/[id]/layout.tsx @@ -1,5 +1,3 @@ -import { auth } from "@/auth"; -import { SessionProvider } from "next-auth/react"; import { Metadata, ResolvingMetadata } from "next"; import { getFullSiteSetup } from "@ui-lib/utils"; import { headers } from "next/headers"; @@ -59,13 +57,8 @@ export default async function Layout(props: { const { children } = props; const { id } = params; - const session = await auth(); const address = await getAddressFromHeaders(headers); const product = await getProduct(id, address); - return ( - - {children} - - ); + return {children}; } diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/action.ts b/apps/web/app/(with-contexts)/dashboard/(sidebar)/action.ts index 58b62904d..c880c0c9b 100644 --- a/apps/web/app/(with-contexts)/dashboard/(sidebar)/action.ts +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/action.ts @@ -9,6 +9,7 @@ import { hasPermissionToAccessSetupChecklist } from "@/lib/utils"; import CourseModel from "@models/Course"; import PageModel from "@models/Page"; import constants from "@config/constants"; +import { headers } from "next/headers"; const DEFAULT_PAGE_CONTENT = "This is the default page created for you by CourseLit"; @@ -17,7 +18,9 @@ export async function getSetupChecklist(): Promise<{ checklist: string[]; total: number; } | null> { - const session = await auth(); + const session = await auth.api.getSession({ + headers: await headers(), + }); if (!session) { return null; } @@ -25,7 +28,7 @@ export async function getSetupChecklist(): Promise<{ try { const domain = await DomainModel.findOne( { - _id: new ObjectId((session.user as any)?.domain!), + _id: new ObjectId((session.user as any)?.domainId!), }, { _id: 1, diff --git a/apps/web/app/(with-contexts)/dashboard/mail/drip/[productId]/[sectionId]/internal/page.tsx b/apps/web/app/(with-contexts)/dashboard/mail/drip/[productId]/[sectionId]/internal/page.tsx index 2983eb4ec..97c486b85 100644 --- a/apps/web/app/(with-contexts)/dashboard/mail/drip/[productId]/[sectionId]/internal/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/mail/drip/[productId]/[sectionId]/internal/page.tsx @@ -89,92 +89,6 @@ export default function EmailEditorPage(props: { } }, [product]); - // const updateGroup = async () => { - // const query = ` - // mutation updateGroup($id: ID!, $courseId: String!, $name: String, $drip: DripInput) { - // course: updateGroup( - // id: $id, - // courseId: $courseId, - // name: $name, - // drip: $drip - // ) { - // courseId, - // groups { - // id, - // name, - // rank, - // collapsed, - // drip { - // type, - // status, - // delayInMillis, - // dateInUTC, - // email { - // content { - // content { - // blockType, - // settings - // }, - // style, - // meta - // }, - // subject - // } - // } - // } - // } - // } - // `; - // const fetch = new FetchBuilder() - // .setUrl(`${address.backend}/api/graph`) - // .setPayload({ - // query, - // variables: { - // id: sectionId, - // courseId: product?.courseId, - // name: sectionName, - // drip: dripType - // ? { - // status: enableDrip, - // type: dripType.toUpperCase().split("-")[0], - // delayInMillis: delay, - // dateInUTC: date, - // email: notifyUsers - // ? { - // subject: emailSubject, - // content: JSON.stringify(emailContent), - // } - // : undefined, - // } - // : undefined, - // }, - // }) - // .setIsGraphQLEndpoint(true) - // .build(); - // console.log(query) - // try { - // setLoading(true); - // const response = await fetch.exec(); - // if (response.course) { - // // router.replace( - // // `/dashboard/product/${productId}/content`, - // // ); - // toast({ - // title: TOAST_TITLE_SUCCESS, - // description: TOAST_DESCRIPTION_CHANGES_SAVED, - // }); - // } - // } catch (err: any) { - // // toast({ - // // title: TOAST_TITLE_ERROR, - // // description: err.message, - // // variant: "destructive", - // // }); - // } finally { - // setLoading(false); - // } - // }; - // Debounced save function const saveEmail = useCallback( async (emailContent: EmailContent) => { diff --git a/apps/web/app/(with-contexts)/dashboard/page.tsx b/apps/web/app/(with-contexts)/dashboard/page.tsx index c140756b4..747fd769a 100644 --- a/apps/web/app/(with-contexts)/dashboard/page.tsx +++ b/apps/web/app/(with-contexts)/dashboard/page.tsx @@ -6,9 +6,6 @@ import { ADMIN_PERMISSIONS } from "@ui-config/constants"; export default async function Page() { const profile = (await getProfile()) as Profile; - if (!profile) { - redirect("/logout/server"); - } if (checkPermission(profile?.permissions, ADMIN_PERMISSIONS)) { redirect("/dashboard/overview"); @@ -16,5 +13,5 @@ export default async function Page() { redirect("/dashboard/my-content"); } - return <>; + return null; } diff --git a/apps/web/app/(with-contexts)/layout-with-context.tsx b/apps/web/app/(with-contexts)/layout-with-context.tsx index 4c7dc347e..c95c697b7 100644 --- a/apps/web/app/(with-contexts)/layout-with-context.tsx +++ b/apps/web/app/(with-contexts)/layout-with-context.tsx @@ -18,11 +18,13 @@ import { } from "@components/contexts"; import { Toaster, useToast } from "@courselit/components-library"; import { TOAST_TITLE_ERROR } from "@ui-config/strings"; -import { Session } from "next-auth"; import { Theme } from "@courselit/page-models"; import { ThemeProvider as NextThemesProvider } from "@components/next-theme-provider"; import { defaultState } from "@components/default-state"; import { getUserProfile } from "./helpers"; +import { auth } from "@/auth"; + +type BetterAuthSession = Awaited> | null; function LayoutContent({ address, @@ -37,7 +39,7 @@ function LayoutContent({ siteinfo: SiteInfo; theme: Theme; config: ServerConfig; - session: Session | null; + session: BetterAuthSession; }) { const [profile, setProfile] = useState(defaultState.profile); const [theme, setTheme] = useState(initialTheme); @@ -104,7 +106,7 @@ export default function Layout(props: { siteinfo: SiteInfo; theme: Theme; config: ServerConfig; - session: Session | null; + session: BetterAuthSession; }) { return ( diff --git a/apps/web/app/(with-contexts)/layout.tsx b/apps/web/app/(with-contexts)/layout.tsx index edba6263c..2f55e719e 100644 --- a/apps/web/app/(with-contexts)/layout.tsx +++ b/apps/web/app/(with-contexts)/layout.tsx @@ -14,7 +14,9 @@ export default async function Layout({ children: React.ReactNode; }) { const address = await getAddressFromHeaders(headers); - const session = await auth(); + const session = await auth.api.getSession({ + headers: await headers(), + }); const siteSetup = await getFullSiteSetup(address); const config: ServerConfig = { diff --git a/apps/web/app/api/auth/[...all]/route.ts b/apps/web/app/api/auth/[...all]/route.ts new file mode 100644 index 000000000..535458328 --- /dev/null +++ b/apps/web/app/api/auth/[...all]/route.ts @@ -0,0 +1,23 @@ +import { als } from "@/async-local-storage"; +import { auth } from "@/auth"; +import { toNextJsHandler } from "better-auth/next-js"; + +const handlers = toNextJsHandler(auth); + +export const POST = async (req: Request) => { + const map = new Map(); + map.set("domain", req.headers.get("domain")); + map.set("domainId", req.headers.get("domainId")); + als.enterWith(map); + + return handlers.POST(req); +}; + +export const GET = async (req: Request, ...rest: any[]) => { + const map = new Map(); + map.set("domain", req.headers.get("domain")); + map.set("domainId", req.headers.get("domainId")); + als.enterWith(map); + + return handlers.GET(req); +}; diff --git a/apps/web/app/api/auth/[...nextauth]/route.ts b/apps/web/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index 86c9f3daa..000000000 --- a/apps/web/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { handlers } from "@/auth"; - -export const { GET, POST } = handlers; diff --git a/apps/web/app/api/auth/code/generate/route.ts b/apps/web/app/api/auth/code/generate/route.ts deleted file mode 100644 index 738565346..000000000 --- a/apps/web/app/api/auth/code/generate/route.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { NextRequest } from "next/server"; -import { responses } from "@/config/strings"; -import { generateUniquePasscode, hashCode } from "@/lib/utils"; -import VerificationToken from "@/models/VerificationToken"; -import pug from "pug"; -import MagicCodeEmailTemplate from "@/templates/magic-code-email"; -import DomainModel, { Domain } from "@models/Domain"; -import { generateEmailFrom } from "@/lib/utils"; -import { addMailJob } from "@/services/queue"; - -export const dynamic = "force-dynamic"; - -export async function GET(req: NextRequest) { - const domain = await DomainModel.findOne({ - name: req.headers.get("domain"), - }); - if (!domain) { - return Response.json({ message: "Domain not found" }, { status: 404 }); - } - - const searchParams = req.nextUrl.searchParams; - const email = searchParams.get("email"); - if (!email) { - return Response.json({ message: "Email is required" }, { status: 400 }); - } - const code = generateUniquePasscode(); - - const sanitizedEmail = (email as string).toLowerCase(); - - await VerificationToken.create({ - domain: domain.name, - email: sanitizedEmail, - code: hashCode(code), - timestamp: Date.now() + 1000 * 60 * 5, - }); - - try { - const emailBody = pug.render(MagicCodeEmailTemplate, { - code, - hideCourseLitBranding: domain.settings?.hideCourseLitBranding, - }); - - await addMailJob({ - to: [sanitizedEmail], - subject: `${responses.sign_in_mail_prefix} ${req.headers.get("host")}`, - body: emailBody, - from: generateEmailFrom({ - name: domain?.settings?.title || domain.name, - email: process.env.EMAIL_FROM || domain.email, - }), - }); - } catch (err: any) { - return Response.json( - { - error: err.message, - }, - { status: 500 }, - ); - } - - return Response.json({}); -} diff --git a/apps/web/app/api/graph/route.ts b/apps/web/app/api/graph/route.ts index 6a2d8eb58..c25e41539 100644 --- a/apps/web/app/api/graph/route.ts +++ b/apps/web/app/api/graph/route.ts @@ -5,6 +5,7 @@ import { getAddress } from "@/lib/utils"; import User from "@models/User"; import DomainModel, { Domain } from "@models/Domain"; import { auth } from "@/auth"; +import { als } from "@/async-local-storage"; async function updateLastActive(user: any) { const dateNow = new Date(); @@ -26,7 +27,18 @@ export async function POST(req: NextRequest) { return Response.json({ message: "Domain not found" }, { status: 404 }); } - const session = await auth(); + const map = new Map(); + map.set("domain", req.headers.get("domain")); + map.set("domainId", req.headers.get("domainId")); + als.enterWith(map); + + const session = await auth.api.getSession({ + headers: req.headers, + }); + const body = await req.json(); + if (!body.hasOwnProperty("query")) { + return Response.json({ error: "Query is missing" }, { status: 400 }); + } let user; if (session) { @@ -41,10 +53,10 @@ export async function POST(req: NextRequest) { } } - const body = await req.json(); - if (!body.hasOwnProperty("query")) { - return Response.json({ error: "Query is missing" }, { status: 400 }); - } + // const body = await req.json(); + // if (!body.hasOwnProperty("query")) { + // return Response.json({ error: "Query is missing" }, { status: 400 }); + // } let query, variables; if (typeof body.query === "string") { diff --git a/apps/web/app/api/media/[mediaId]/[type]/route.ts b/apps/web/app/api/media/[mediaId]/[type]/route.ts index 209c0fff5..1579b48af 100644 --- a/apps/web/app/api/media/[mediaId]/[type]/route.ts +++ b/apps/web/app/api/media/[mediaId]/[type]/route.ts @@ -37,7 +37,9 @@ export async function DELETE( return Response.json({ message: "Domain not found" }, { status: 404 }); } - const session = await auth(); + const session = await auth.api.getSession({ + headers: req.headers, + }); let user; if (session) { diff --git a/apps/web/app/api/media/presigned/route.ts b/apps/web/app/api/media/presigned/route.ts index 6a818ee81..a893bc98b 100644 --- a/apps/web/app/api/media/presigned/route.ts +++ b/apps/web/app/api/media/presigned/route.ts @@ -16,7 +16,9 @@ export async function POST(req: NextRequest) { return Response.json({ message: "Domain not found" }, { status: 404 }); } - const session = await auth(); + const session = await auth.api.getSession({ + headers: req.headers, + }); let user; if (session) { diff --git a/apps/web/app/api/payment/initiate/__tests__/integration.test.ts b/apps/web/app/api/payment/initiate/__tests__/integration.test.ts index f0a1f55b9..037028565 100644 --- a/apps/web/app/api/payment/initiate/__tests__/integration.test.ts +++ b/apps/web/app/api/payment/initiate/__tests__/integration.test.ts @@ -29,11 +29,28 @@ jest.mock("@models/Community"); jest.mock("@models/PaymentPlan"); jest.mock("@models/Membership"); jest.mock("@models/Invoice"); -jest.mock("@/auth"); +jest.mock("@/auth", () => ({ + auth: { + api: { + getSession: jest.fn(), + }, + }, +})); jest.mock("@/payments-new"); jest.mock("../../helpers"); jest.mock("@/graphql/users/logic"); jest.mock("@/graphql/paymentplans/logic"); +jest.mock("better-auth", () => ({ + betterAuth: jest.fn(), + APIError: class extends Error {}, +})); +jest.mock("better-auth/plugins", () => ({ + customSession: jest.fn(), + emailOTP: jest.fn(), +})); +jest.mock("better-auth/adapters", () => ({ + createAdapterFactory: jest.fn(), +})); describe("Payment Initiate Integration Tests - Included Products", () => { const mockDomainId = new mongoose.Types.ObjectId( @@ -158,7 +175,7 @@ describe("Payment Initiate Integration Tests - Included Products", () => { } as unknown as NextRequest; // Mock auth - (auth as jest.Mock).mockResolvedValue({ + (auth.api.getSession as unknown as jest.Mock).mockResolvedValue({ user: { email: "test@test.com", }, diff --git a/apps/web/app/api/payment/initiate/__tests__/route.test.ts b/apps/web/app/api/payment/initiate/__tests__/route.test.ts index 5119acf35..8a9f35a6b 100644 --- a/apps/web/app/api/payment/initiate/__tests__/route.test.ts +++ b/apps/web/app/api/payment/initiate/__tests__/route.test.ts @@ -20,7 +20,13 @@ jest.mock("@models/Course"); jest.mock("@models/PaymentPlan"); jest.mock("@models/Invoice"); jest.mock("@models/Community"); -jest.mock("@/auth"); +jest.mock("@/auth", () => ({ + auth: { + api: { + getSession: jest.fn(), + }, + }, +})); jest.mock("../../helpers"); jest.mock("@/graphql/users/logic"); jest.mock("@/payments-new"); @@ -68,7 +74,7 @@ describe("Payment Initiate Route", () => { }, } as unknown as NextRequest; - (auth as jest.Mock).mockResolvedValue({ + (auth.api.getSession as jest.Mock).mockResolvedValue({ user: { email: "test@test.com", }, @@ -119,7 +125,7 @@ describe("Payment Initiate Route", () => { }); it("returns 401 if user is not authenticated", async () => { - (auth as jest.Mock).mockResolvedValue(null); + (auth.api.getSession as jest.Mock).mockResolvedValue(null); const response = await POST(mockRequest); expect(response.status).toBe(401); diff --git a/apps/web/app/api/payment/initiate/route.ts b/apps/web/app/api/payment/initiate/route.ts index 2408c9ddc..3fc3b6f07 100644 --- a/apps/web/app/api/payment/initiate/route.ts +++ b/apps/web/app/api/payment/initiate/route.ts @@ -46,7 +46,9 @@ export async function POST(req: NextRequest) { ); } - const session = await auth(); + const session = await auth.api.getSession({ + headers: req.headers, + }); const user = await getUser(session, domain._id); if (!user) { diff --git a/apps/web/app/api/payment/verify-new/route.ts b/apps/web/app/api/payment/verify-new/route.ts index 2ddbf437d..87729e8dd 100644 --- a/apps/web/app/api/payment/verify-new/route.ts +++ b/apps/web/app/api/payment/verify-new/route.ts @@ -22,7 +22,9 @@ export async function POST(req: NextRequest) { ); } - const session = await auth(); + const session = await auth.api.getSession({ + headers: req.headers, + }); const user = await getUser(session, domain._id); if (!user) { diff --git a/apps/web/app/verify-domain/route.ts b/apps/web/app/verify-domain/route.ts index 1f170f778..e5571ceb9 100644 --- a/apps/web/app/verify-domain/route.ts +++ b/apps/web/app/verify-domain/route.ts @@ -182,7 +182,11 @@ export async function GET(req: Request) { return Response.json({ success: true, domain: domain!.name, + domainId: domain!._id.toString(), logo: domain!.settings?.logo?.file, + domainEmail: domain!.email, + domainTitle: domain!.settings?.title, + hideCourseLitBranding: domain!.settings?.hideCourseLitBranding, }); } diff --git a/apps/web/async-local-storage.ts b/apps/web/async-local-storage.ts new file mode 100644 index 000000000..5d5d9ba25 --- /dev/null +++ b/apps/web/async-local-storage.ts @@ -0,0 +1,3 @@ +import { AsyncLocalStorage } from "node:async_hooks"; + +export const als = new AsyncLocalStorage>(); diff --git a/apps/web/auth.config.ts b/apps/web/auth.config.ts deleted file mode 100644 index 624fc63f9..000000000 --- a/apps/web/auth.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -// @ts-nocheck -import { type NextAuthConfig } from "next-auth"; - -export const authConfig = { - providers: [], - pages: { - signIn: "/login", - }, - callbacks: { - async redirect({ url }) { - return url; - }, - }, -} satisfies NextAuthConfig; diff --git a/apps/web/auth.ts b/apps/web/auth.ts index 9ff5c05d4..724914e45 100644 --- a/apps/web/auth.ts +++ b/apps/web/auth.ts @@ -1,102 +1,116 @@ -import NextAuth, { Session } from "next-auth"; -import { z } from "zod"; -import { authConfig } from "./auth.config"; -import CredentialsProvider from "next-auth/providers/credentials"; -import VerificationToken from "@models/VerificationToken"; -import UserModel from "@models/User"; -import { createUser } from "./graphql/users/logic"; -import { hashCode } from "@/lib/utils"; +import { APIError, betterAuth } from "better-auth"; +import { customSession, emailOTP } from "better-auth/plugins"; +import { MongoClient } from "mongodb"; import DomainModel, { Domain } from "@models/Domain"; -import { error } from "./services/logger"; -import { User } from "next-auth"; -import { User as AppUser } from "@courselit/common-models"; +import { addMailJob } from "@/services/queue"; +import pug from "pug"; +import MagicCodeEmailTemplate from "@/templates/magic-code-email"; +import { generateEmailFrom } from "@/lib/utils"; +import { responses } from "@/config/strings"; +import { mongodbAdapter } from "@/ba-multitenant-adapter"; +import { updateUserAfterCreationViaAuth } from "./graphql/users/logic"; +import UserModel from "@models/User"; +import { getBackendAddress } from "./app/actions"; -type AuthReturn = ReturnType; +const client = new MongoClient(process.env.DB_CONNECTION_STRING || ""); +const db = client.db(); -const authHandlers: AuthReturn = NextAuth({ - ...authConfig, - providers: [ - CredentialsProvider({ - name: "Email", - credentials: {}, - async authorize(credentials, req) { - const domain = await DomainModel.findOne({ - name: req.headers.get("domain"), +const config: any = { + appName: "CourseLit", + secret: process.env.AUTH_SECRET, + advanced: { + cookiePrefix: "courselit", + }, + database: mongodbAdapter(db, { + client, + usePlural: true, + }), + plugins: [ + emailOTP({ + overrideDefaultEmailVerification: true, + storeOTP: "hashed", + async sendVerificationOTP({ email, otp, type }, ctx) { + const emailBody = pug.render(MagicCodeEmailTemplate, { + code: otp, + hideCourseLitBranding: + ctx!.headers?.get("hidecourselitbranding") || false, }); - if (!domain) { - throw new Error("Domain not found"); - } - const parsedCredentials = z - .object({ - email: z.string().email(), - code: z.string().min(6), - }) - .safeParse(credentials); - if (!parsedCredentials.success) { - return null; - } - - const { email, code } = parsedCredentials.data; - const sanitizedEmail = email.toLowerCase(); - const verificationToken = - await VerificationToken.findOneAndDelete({ - email: sanitizedEmail, - domain: domain.name, - code: hashCode(+code), - timestamp: { $gt: Date.now() }, - }); - if (!verificationToken) { - error(`Invalid code`, { - email: sanitizedEmail, - }); - return null; - } - - let user = await UserModel.findOne({ - domain: domain._id, - email: sanitizedEmail, + await addMailJob({ + to: [email], + subject: `${responses.sign_in_mail_prefix} ${ctx!.headers?.get("domain")}`, + body: emailBody, + from: generateEmailFrom({ + name: + ctx!.headers?.get("domainTitle") || + ctx!.headers?.get("domain") || + "", + email: + process.env.EMAIL_FROM || + ctx!.headers?.get("domainemail") || + "", + }), }); - if (user && user.invited) { - user.invited = false; - await user.save(); - } - if (!user) { - user = await createUser({ - domain, - email: sanitizedEmail, - }); - } - if (!user.active) { - return null; - } - return user; }, }), + customSession(async ({ user, session }, ctx) => { + return { + user: { + ...user, + userId: ( + (await UserModel.findOne({ _id: user.id }) + .select("userId") + .lean()) as unknown as any + ).userId, + }, + session: { + ...session, + domainId: ctx.headers?.get("domainId"), + }, + }; + }), ], - callbacks: { - jwt({ token, user }: { token: any; user?: User }) { - if (user) { - token.userId = (user as unknown as AppUser).userId; - token.domain = (user as any).domain.toString(); - } - return token; - }, - session({ session, token }: { session: Session; token: any }) { - if (session.user && token.userId) { - if (token.userId) { - (session.user as any).userId = token.userId; - } - if (token.domain) { - (session.user as any).domain = token.domain; - } - } - return session; + databaseHooks: { + user: { + create: { + after: async (user, ctx) => { + const domainName = ctx!.headers?.get("domain"); + const domain = (await DomainModel.findOne({ + name: domainName, + }).lean()) as unknown as Domain; + if (!domain) { + throw new APIError("NOT_FOUND", { + message: "Domain not found", + }); + } + + await updateUserAfterCreationViaAuth(user.id, domain); + }, + }, }, }, -}); + trustedOrigins: async (request: Request) => { + const backendAddress = await getBackendAddress(request.headers); + return [backendAddress]; + }, +}; + +if (process.env.SESSION_COOKIE_CACHE_MAX_AGE) { + if (parseInt(process.env.SESSION_COOKIE_CACHE_MAX_AGE) > 0) { + config.session = { + cookieCache: { + enabled: true, + maxAge: parseInt(process.env.SESSION_COOKIE_CACHE_MAX_AGE) * 60, + }, + }; + } +} else { + config.session = { + cookieCache: { + enabled: true, + maxAge: 5 * 60, // 5 minutes + }, + }; +} -export const auth: AuthReturn["auth"] = authHandlers.auth; -export const signIn: AuthReturn["signIn"] = authHandlers.signIn; -export const signOut: AuthReturn["signOut"] = authHandlers.signOut; -export const handlers: AuthReturn["handlers"] = authHandlers.handlers; +export const auth = betterAuth(config); diff --git a/apps/web/ba-multitenant-adapter.ts b/apps/web/ba-multitenant-adapter.ts new file mode 100644 index 000000000..815578b49 --- /dev/null +++ b/apps/web/ba-multitenant-adapter.ts @@ -0,0 +1,768 @@ +import type { ClientSession, Db, MongoClient } from "mongodb"; +import { ObjectId } from "mongodb"; +import { + createAdapterFactory, + AdapterFactoryCustomizeAdapterCreator, + AdapterFactoryOptions, + DBAdapterDebugLogOption, + Where, + DBAdapter, +} from "better-auth/adapters"; +import { BetterAuthOptions } from "better-auth/*"; +import { als } from "./async-local-storage"; + +export interface MongoDBAdapterConfig { + /** + * MongoDB client instance + * If not provided, Database transactions won't be enabled. + */ + client?: MongoClient | undefined; + /** + * Enable debug logs for the adapter + * + * @default false + */ + debugLogs?: DBAdapterDebugLogOption | undefined; + /** + * Use plural table names + * + * @default false + */ + usePlural?: boolean | undefined; + /** + * Whether to execute multiple operations in a transaction. + * + * If the database doesn't support transactions, + * set this to `false` and operations will be executed sequentially. + * @default false + */ + transaction?: boolean | undefined; +} + +export const mongodbAdapter = ( + db: Db, + config?: MongoDBAdapterConfig | undefined, +) => { + let lazyOptions: BetterAuthOptions | null; + + const getCustomIdGenerator = (options: BetterAuthOptions) => { + const generator = options.advanced?.database?.generateId; + if (typeof generator === "function") { + return generator; + } + return undefined; + }; + + const createCustomAdapter = + ( + db: Db, + session?: ClientSession | undefined, + ): AdapterFactoryCustomizeAdapterCreator => + ({ + getFieldAttributes, + getFieldName, + schema, + getDefaultModelName, + options, + }) => { + const customIdGen = getCustomIdGenerator(options); + + function serializeID({ + field, + value, + model, + }: { + field: string; + value: any; + model: string; + }) { + if (customIdGen) { + return value; + } + model = getDefaultModelName(model); + if ( + field === "id" || + field === "_id" || + schema[model]!.fields[field]?.references?.field === "id" + ) { + if (value === null || value === undefined) { + return value; + } + if (typeof value !== "string") { + if (value instanceof ObjectId) { + return value; + } + if (Array.isArray(value)) { + return value.map((v) => { + if (v === null || v === undefined) { + return v; + } + if (typeof v === "string") { + try { + return new ObjectId(v); + } catch (e) { + return v; + } + } + if (v instanceof ObjectId) { + return v; + } + throw new Error( + "Invalid id value, recieved: " + + JSON.stringify(v), + ); + }); + } + throw new Error( + "Invalid id value, recieved: " + + JSON.stringify(value), + ); + } + try { + return new ObjectId(value); + } catch (e) { + return value; + } + } + return value; + } + + function convertWhereClause({ + where, + model, + }: { + where: Where[]; + model: string; + }) { + if (!where.length) return {}; + const conditions = where.map((w) => { + const { + field: field_, + value, + operator = "eq", + connector = "AND", + } = w; + let condition: any; + let field = getFieldName({ model, field: field_ }); + if (field === "id") field = "_id"; + switch (operator.toLowerCase()) { + case "eq": + condition = { + [field]: serializeID({ + field, + value, + model, + }), + }; + break; + case "in": + condition = { + [field]: { + $in: Array.isArray(value) + ? value.map((v) => + serializeID({ + field, + value: v, + model, + }), + ) + : [ + serializeID({ + field, + value, + model, + }), + ], + }, + }; + break; + case "not_in": + condition = { + [field]: { + $nin: Array.isArray(value) + ? value.map((v) => + serializeID({ + field, + value: v, + model, + }), + ) + : [ + serializeID({ + field, + value, + model, + }), + ], + }, + }; + break; + case "gt": + condition = { + [field]: { + $gt: serializeID({ + field, + value, + model, + }), + }, + }; + break; + case "gte": + condition = { + [field]: { + $gte: serializeID({ + field, + value, + model, + }), + }, + }; + break; + case "lt": + condition = { + [field]: { + $lt: serializeID({ + field, + value, + model, + }), + }, + }; + break; + case "lte": + condition = { + [field]: { + $lte: serializeID({ + field, + value, + model, + }), + }, + }; + break; + case "ne": + condition = { + [field]: { + $ne: serializeID({ + field, + value, + model, + }), + }, + }; + break; + case "contains": + condition = { + [field]: { + $regex: `.*${escapeForMongoRegex(value as string)}.*`, + }, + }; + break; + case "starts_with": + condition = { + [field]: { + $regex: `^${escapeForMongoRegex(value as string)}`, + }, + }; + break; + case "ends_with": + condition = { + [field]: { + $regex: `${escapeForMongoRegex(value as string)}$`, + }, + }; + break; + default: + throw new Error( + `Unsupported operator: ${operator}`, + ); + } + return { condition, connector }; + }); + // Push tenant filter to the where clause + if (als.getStore()?.get("domainId")) { + conditions.push({ + condition: { + domain: new ObjectId( + als.getStore()?.get("domainId") ?? "", + ), + }, + connector: "AND", + }); + } + if (conditions.length === 1) { + return conditions[0]!.condition; + } + const andConditions = conditions + .filter((c) => c.connector === "AND") + .map((c) => c.condition); + const orConditions = conditions + .filter((c) => c.connector === "OR") + .map((c) => c.condition); + + let clause = {}; + if (andConditions.length) { + clause = { ...clause, $and: andConditions }; + } + if (orConditions.length) { + clause = { ...clause, $or: orConditions }; + } + return clause; + } + + return { + async create({ model, data: values }) { + (values as any).domain = new ObjectId( + als.getStore()?.get("domainId") ?? "", + ); + const res = await db + .collection(model) + .insertOne(values, { session }); + const insertedData = { + _id: res.insertedId.toString(), + ...values, + }; + return insertedData as any; + }, + async findOne({ model, where, select, join }) { + const matchStage = where + ? { $match: convertWhereClause({ where, model }) } + : { $match: {} }; + const pipeline: any[] = [matchStage]; + + if (join) { + for (const [joinedModel, joinConfig] of Object.entries( + join, + )) { + const localField = getFieldName({ + field: joinConfig.on.from, + model, + }); + const foreignField = getFieldName({ + field: joinConfig.on.to, + model: joinedModel, + }); + + const localFieldName = + localField === "id" ? "_id" : localField; + const foreignFieldName = + foreignField === "id" ? "_id" : foreignField; + + // Only unwind if the foreign field has a unique constraint (one-to-one relationship) + const joinedModelSchema = + schema[getDefaultModelName(joinedModel)]; + const foreignFieldAttribute = + joinedModelSchema?.fields[joinConfig.on.to]; + const isUnique = + foreignFieldAttribute?.unique === true; + + // For unique relationships, limit is ignored (as per JoinConfig type) + // For non-unique relationships, apply limit if specified + const shouldLimit = + !isUnique && joinConfig.limit !== undefined; + let limit = + joinConfig.limit ?? + options.advanced?.database + ?.defaultFindManyLimit ?? + 100; + if (shouldLimit && limit > 0) { + // Use pipeline syntax to support limit + // Construct the field reference string for the foreign field + const foreignFieldRef = `$${foreignFieldName}`; + pipeline.push({ + $lookup: { + from: joinedModel, + let: { + localFieldValue: `$${localFieldName}`, + }, + pipeline: [ + { + $match: { + $expr: { + $eq: [ + foreignFieldRef, + "$$localFieldValue", + ], + }, + }, + }, + { $limit: limit }, + ], + as: joinedModel, + }, + }); + } else { + // Use simple syntax when no limit is needed + pipeline.push({ + $lookup: { + from: joinedModel, + localField: localFieldName, + foreignField: foreignFieldName, + as: joinedModel, + }, + }); + } + + if (isUnique) { + // For one-to-one relationships, unwind to flatten to a single object + pipeline.push({ + $unwind: { + path: `$${joinedModel}`, + preserveNullAndEmptyArrays: true, + }, + }); + } + // For one-to-many, keep as array - no unwind + } + } + + if (select) { + const projection: any = {}; + select.forEach((field) => { + projection[getFieldName({ field, model })] = 1; + }); + + // Include joined collections in projection + if (join) { + for (const joinedModel of Object.keys(join)) { + projection[joinedModel] = 1; + } + } + + pipeline.push({ $project: projection }); + } + + pipeline.push({ $limit: 1 }); + + const res = await db + .collection(model) + .aggregate(pipeline, { session }) + .toArray(); + + if (!res || res.length === 0) return null; + return res[0] as any; + }, + async findMany({ model, where, limit, offset, sortBy, join }) { + const matchStage = where + ? { $match: convertWhereClause({ where, model }) } + : { $match: {} }; + const pipeline: any[] = [matchStage]; + + if (join) { + for (const [joinedModel, joinConfig] of Object.entries( + join, + )) { + const localField = getFieldName({ + field: joinConfig.on.from, + model, + }); + const foreignField = getFieldName({ + field: joinConfig.on.to, + model: joinedModel, + }); + + const localFieldName = + localField === "id" ? "_id" : localField; + const foreignFieldName = + foreignField === "id" ? "_id" : foreignField; + + // Only unwind if the foreign field has a unique constraint (one-to-one relationship) + const foreignFieldAttribute = getFieldAttributes({ + model: joinedModel, + field: joinConfig.on.to, + }); + const isUnique = + foreignFieldAttribute?.unique === true; + + // For unique relationships, limit is ignored (as per JoinConfig type) + // For non-unique relationships, apply limit if specified + const shouldLimit = + joinConfig.relation !== "one-to-one" && + joinConfig.limit !== undefined; + + let limit = + joinConfig.limit ?? + options.advanced?.database + ?.defaultFindManyLimit ?? + 100; + if (shouldLimit && limit > 0) { + // Use pipeline syntax to support limit + // Construct the field reference string for the foreign field + const foreignFieldRef = `$${foreignFieldName}`; + pipeline.push({ + $lookup: { + from: joinedModel, + let: { + localFieldValue: `$${localFieldName}`, + }, + pipeline: [ + { + $match: { + $expr: { + $eq: [ + foreignFieldRef, + "$$localFieldValue", + ], + }, + }, + }, + { $limit: limit }, + ], + as: joinedModel, + }, + }); + } else { + // Use simple syntax when no limit is needed + pipeline.push({ + $lookup: { + from: joinedModel, + localField: localFieldName, + foreignField: foreignFieldName, + as: joinedModel, + }, + }); + } + + if (isUnique) { + // For one-to-one relationships, unwind to flatten to a single object + pipeline.push({ + $unwind: { + path: `$${joinedModel}`, + preserveNullAndEmptyArrays: true, + }, + }); + } + // For one-to-many, keep as array - no unwind + } + } + + if (sortBy) { + pipeline.push({ + $sort: { + [getFieldName({ field: sortBy.field, model })]: + sortBy.direction === "desc" ? -1 : 1, + }, + }); + } + + if (offset) { + pipeline.push({ $skip: offset }); + } + + if (limit) { + pipeline.push({ $limit: limit }); + } + + const res = await db + .collection(model) + .aggregate(pipeline, { session }) + .toArray(); + + return res as any; + }, + async count({ model, where }) { + const matchStage = where + ? { $match: convertWhereClause({ where, model }) } + : { $match: {} }; + const pipeline: any[] = [matchStage, { $count: "total" }]; + + const res = await db + .collection(model) + .aggregate(pipeline, { session }) + .toArray(); + + if (!res || res.length === 0) return 0; + return res[0]?.total ?? 0; + }, + async update({ model, where, update: values }) { + const clause = convertWhereClause({ where, model }); + + const res = await db.collection(model).findOneAndUpdate( + clause, + { $set: values as any }, + { + session, + returnDocument: "after", + includeResultMetadata: true, + }, + ); + const doc = (res as any)?.value ?? null; + if (!doc) return null; + return doc as any; + }, + async updateMany({ model, where, update: values }) { + const clause = convertWhereClause({ where, model }); + + const res = await db.collection(model).updateMany( + clause, + { + $set: values as any, + }, + { session }, + ); + return res.modifiedCount; + }, + async delete({ model, where }) { + const clause = convertWhereClause({ where, model }); + await db.collection(model).deleteOne(clause, { session }); + }, + async deleteMany({ model, where }) { + const clause = convertWhereClause({ where, model }); + const res = await db + .collection(model) + .deleteMany(clause, { session }); + return res.deletedCount; + }, + }; + }; + + let lazyAdapter: + | ((options: BetterAuthOptions) => DBAdapter) + | null = null; + let adapterOptions: AdapterFactoryOptions | null = null; + adapterOptions = { + config: { + adapterId: "multitenant-mongodb-adapter", + adapterName: "Multitenant MongoDB Adapter", + usePlural: config?.usePlural ?? false, + debugLogs: config?.debugLogs ?? false, + mapKeysTransformInput: { + id: "_id", + }, + mapKeysTransformOutput: { + _id: "id", + }, + supportsNumericIds: false, + transaction: + config?.client && (config?.transaction ?? true) + ? async (cb) => { + if (!config.client) { + return cb(lazyAdapter!(lazyOptions!)); + } + + const session = config.client.startSession(); + + try { + session.startTransaction(); + + const adapter = createAdapterFactory({ + config: adapterOptions!.config, + adapter: createCustomAdapter(db, session), + })(lazyOptions!); + + const result = await cb(adapter); + + await session.commitTransaction(); + return result; + } catch (err) { + await session.abortTransaction(); + throw err; + } finally { + await session.endSession(); + } + } + : false, + customTransformInput({ + action, + data, + field, + fieldAttributes, + schema, + model, + options, + }) { + const customIdGen = getCustomIdGenerator(options); + if ( + field === "_id" || + fieldAttributes.references?.field === "id" + ) { + if (customIdGen) { + return data; + } + if (action === "update") { + return data; + } + if (Array.isArray(data)) { + return data.map((v) => { + if (typeof v === "string") { + try { + const oid = new ObjectId(v); + return oid; + } catch (error) { + return v; + } + } + return v; + }); + } + if (typeof data === "string") { + try { + const oid = new ObjectId(data); + return oid; + } catch (error) { + return data; + } + } + if ( + fieldAttributes?.references?.field === "id" && + !fieldAttributes?.required && + data === null + ) { + return null; + } + const oid = new ObjectId(); + return oid; + } + return data; + }, + customTransformOutput({ data, field, fieldAttributes }) { + if ( + field === "id" || + fieldAttributes.references?.field === "id" + ) { + if (data instanceof ObjectId) { + return data.toHexString(); + } + if (Array.isArray(data)) { + return data.map((v) => { + if (v instanceof ObjectId) { + return v.toHexString(); + } + return v; + }); + } + return data; + } + return data; + }, + customIdGenerator() { + return new ObjectId().toString(); + }, + }, + adapter: createCustomAdapter(db), + }; + lazyAdapter = createAdapterFactory(adapterOptions); + + return (options: BetterAuthOptions): DBAdapter => { + lazyOptions = options; + return lazyAdapter(options); + }; +}; + +/** + * Safely escape user input for use in a MongoDB regex. + * This ensures the resulting pattern is treated as literal text, + * and not as a regex with special syntax. + * + * @param input - The input string to escape. Any type that isn't a string will be converted to an empty string. + * @param maxLength - The maximum length of the input string to escape. Defaults to 256. This is to prevent DOS attacks. + * @returns The escaped string. + */ +function escapeForMongoRegex(input: string, maxLength = 256): string { + if (typeof input !== "string") return ""; + + // Escape all PCRE special characters + // Source: PCRE docs — https://www.pcre.org/original/doc/html/pcrepattern.html + return input.slice(0, maxLength).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/apps/web/components/admin/products/new-customer.tsx b/apps/web/components/admin/products/new-customer.tsx index c4a625b17..a17c1bd5d 100644 --- a/apps/web/components/admin/products/new-customer.tsx +++ b/apps/web/components/admin/products/new-customer.tsx @@ -74,7 +74,6 @@ export default function NewCustomer({ courseId }: NewCustomerProps) { permissions, userId, tags, - invited } } `; diff --git a/apps/web/components/public/payments/login-form.tsx b/apps/web/components/public/payments/login-form.tsx index 0b06f52c8..4f479f9eb 100644 --- a/apps/web/components/public/payments/login-form.tsx +++ b/apps/web/components/public/payments/login-form.tsx @@ -4,8 +4,14 @@ import { useContext, useEffect, useState } from "react"; import { useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; -// import { Button } from "@/components/ui/button"; -import { Button, Input, Text1, Text2 } from "@courselit/page-primitives"; +import { + Button, + Caption, + Input, + Link as PageLink, + Text1, + Text2, +} from "@courselit/page-primitives"; import { FormControl, FormField, @@ -18,17 +24,21 @@ import { ThemeContext, } from "@components/contexts"; import { useToast } from "@courselit/components-library"; -import { TOAST_TITLE_ERROR } from "@ui-config/strings"; -import { signIn } from "next-auth/react"; +import { + LOGIN_CODE_INTIMATION_MESSAGE, + LOGIN_FORM_DISCLAIMER, + TOAST_TITLE_ERROR, +} from "@ui-config/strings"; import { getUserProfile } from "@/app/(with-contexts)/helpers"; +import { authClient } from "@/lib/auth-client"; +import Link from "next/link"; const loginFormSchema = z.object({ email: z.string().email("Invalid email address"), otp: z.string().min(6, "OTP must be at least 6 characters").optional(), - name: z.string().min(2, "Name must be at least 2 characters").optional(), }); -type LoginStep = "email" | "otp" | "name" | "complete"; +type LoginStep = "email" | "otp" | "complete"; interface LoginFormProps { onLoginComplete: (email: string, name: string) => void; @@ -54,7 +64,6 @@ export function LoginForm({ onLoginComplete }: LoginFormProps) { defaultValues: { email: "", otp: "", - name: "", }, }); @@ -69,30 +78,24 @@ export function LoginForm({ onLoginComplete }: LoginFormProps) { } await requestCode(emailValue); - - // Simulate OTP request - setLoginStep("otp"); }; const requestCode = async function (email: string) { - const url = `/api/auth/code/generate?email=${encodeURIComponent( - email, - )}`; try { setLoading(true); - const response = await fetch(url); - const resp = await response.json(); - if (!response.ok) { + const { error } = await authClient.emailOtp.sendVerificationOtp({ + email: email.trim().toLowerCase(), + type: "sign-in", + }); + if (error) { toast({ title: TOAST_TITLE_ERROR, - description: resp.error, + description: error.message, + variant: "destructive", }); + } else { + setLoginStep("otp"); } - } catch (err) { - toast({ - title: TOAST_TITLE_ERROR, - description: err.message, - }); } finally { setLoading(false); } @@ -103,15 +106,15 @@ export function LoginForm({ onLoginComplete }: LoginFormProps) { const code = form.getValues("otp"); try { setLoading(true); - const response = await signIn("credentials", { - email, - code, - redirect: false, + const { error } = await authClient.signIn.emailOtp({ + email: email.trim().toLowerCase(), + otp: code!, }); - if (response?.error) { + if (error) { toast({ title: TOAST_TITLE_ERROR, - description: `Can't sign you in at this time`, + description: error.message, + variant: "destructive", }); } else { const profile = await getUserProfile(address.backend); @@ -124,12 +127,6 @@ export function LoginForm({ onLoginComplete }: LoginFormProps) { } }; - const handleNameSubmit = () => { - const { email, name } = form.getValues(); - onLoginComplete(email, name || ""); - setLoginStep("complete"); - }; - const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "Enter") { event.preventDefault(); @@ -137,8 +134,6 @@ export function LoginForm({ onLoginComplete }: LoginFormProps) { handleRequestOTP(); } else if (loginStep === "otp") { handleVerifyOTP(); - } else if (loginStep === "name") { - handleNameSubmit(); } } }; @@ -166,7 +161,30 @@ export function LoginForm({ onLoginComplete }: LoginFormProps) { )} /> - By signing in, you accept our{" "} + + {LOGIN_FORM_DISCLAIMER} + + + Terms + + {" "} + and{" "} + + + Privacy Policy + + + + {/* By signing in, you accept our{" "} Privacy Policy - + */} - - )} ); diff --git a/apps/web/components/public/session-button.tsx b/apps/web/components/public/session-button.tsx deleted file mode 100644 index 007ad7aef..000000000 --- a/apps/web/components/public/session-button.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from "react"; -import { - GENERIC_SIGNOUT_TEXT, - GENERIC_SIGNIN_TEXT, -} from "../../ui-config/strings"; -import { Button } from "@courselit/components-library"; -import { signIn, signOut, useSession } from "next-auth/react"; - -export default function SessionButton() { - const { data: session } = useSession(); - - if (session) { - return ( - - ); - } - - return ( - - ); -} diff --git a/apps/web/config/constants.ts b/apps/web/config/constants.ts index e85d52cf1..f4c646673 100644 --- a/apps/web/config/constants.ts +++ b/apps/web/config/constants.ts @@ -9,11 +9,6 @@ export default { domainNameForSingleTenancy: process.env.DOMAIN_NAME_FOR_SINGLE_TENANCY || "main", schoolNameForSingleTenancy: "My school", - dbConnectionString: - process.env.DB_CONNECTION_STRING || - `mongodb://localhost/${ - process.env.NODE_ENV === "test" ? "test" : "app" - }`, // product types course: "course", diff --git a/apps/web/graphql/pages/page-templates.ts b/apps/web/graphql/pages/page-templates.ts index 2ed63047c..2a3aecb1d 100644 --- a/apps/web/graphql/pages/page-templates.ts +++ b/apps/web/graphql/pages/page-templates.ts @@ -71,7 +71,7 @@ export const homePageTemplate = [ }, buttonAction: "/products", buttonCaption: "Ask user to take action", - youtubeLink: "https://www.youtube.com/watch?v=VLVcZB2-udk", + youtubeLink: "https://www.youtube.com/watch?v=7OP2bU9RWVE", alignment: "right", style: "normal", mediaRadius: 2, diff --git a/apps/web/graphql/users/logic.ts b/apps/web/graphql/users/logic.ts index 843d1305d..9669fefaf 100644 --- a/apps/web/graphql/users/logic.ts +++ b/apps/web/graphql/users/logic.ts @@ -7,7 +7,7 @@ import constants from "@/config/constants"; import GQLContext from "@/models/GQLContext"; import { initMandatoryPages } from "../pages/logic"; import { Domain } from "@models/Domain"; -import { checkPermission } from "@courselit/utils"; +import { checkPermission, generateUniqueId } from "@courselit/utils"; import UserSegmentModel from "@models/UserSegment"; import { InternalCourse, @@ -59,6 +59,7 @@ import { cleanupPersonalData, } from "./helpers"; const { permissions } = UIConstants; +import { ObjectId } from "mongodb"; const removeAdminFieldsFromUserObject = (user: any) => ({ id: user._id, @@ -69,7 +70,10 @@ const removeAdminFieldsFromUserObject = (user: any) => ({ avatar: user.avatar, }); -export const getUser = async (userId = null, ctx: GQLContext) => { +export const getUser = async ( + userId: string | null = null, + ctx: GQLContext, +) => { let user: any = ctx.user; if (userId) { @@ -171,7 +175,6 @@ export const inviteCustomer = async ( domain: ctx.subdomain!, email: sanitizedEmail, subscribedToUpdates: true, - invited: true, }); } @@ -359,7 +362,6 @@ export async function createUser({ lead, superAdmin = false, subscribedToUpdates = true, - invited, permissions = [], }: { domain: Domain; @@ -372,7 +374,6 @@ export async function createUser({ | typeof constants.leadDownload; superAdmin?: boolean; subscribedToUpdates?: boolean; - invited?: boolean; permissions?: string[]; }): Promise { if (permissions.length) { @@ -406,7 +407,6 @@ export async function createUser({ ], lead: lead || constants.leadWebsite, subscribedToUpdates, - invited, }, }, { upsert: true, new: true, includeResultMetadata: true }, @@ -421,27 +421,65 @@ export async function createUser({ await createInternalPaymentPlan(domain, createdUser.userId); } - await recordActivity({ + await recordActivityAndTriggerSequences(createdUser, domain); + } + + return createdUser; +} + +export async function updateUserAfterCreationViaAuth( + id: string, + domain: Domain, +) { + const updatedUser = await UserModel.findOneAndUpdate( + { + _id: new ObjectId(id), domain: domain._id, - userId: createdUser.userId, - type: "user_created", - }); + }, + { + $set: { + domain: domain._id, + userId: generateUniqueId(), + active: true, + purchases: [], + permissions: [ + constants.permissions.enrollInCourse, + constants.permissions.manageMedia, + ], + lead: constants.leadWebsite, + subscribedToUpdates: true, + tags: [], + unsubscribeToken: generateUniqueId(), + }, + }, + { new: true }, + ); - if (createdUser.subscribedToUpdates) { - await triggerSequences({ - user: createdUser, - event: Constants.EventType.SUBSCRIBER_ADDED, - }); + await recordActivityAndTriggerSequences(updatedUser, domain); +} - await recordActivity({ - domain: domain!._id, - userId: createdUser.userId, - type: "newsletter_subscribed", - }); - } - } +async function recordActivityAndTriggerSequences( + user: InternalUser, + domain: Domain, +) { + await recordActivity({ + domain: domain._id, + userId: user.userId, + type: Constants.ActivityType.USER_CREATED, + }); - return createdUser; + if (user.subscribedToUpdates) { + await triggerSequences({ + user: user, + event: Constants.EventType.SUBSCRIBER_ADDED, + }); + + await recordActivity({ + domain: domain!._id, + userId: user.userId, + type: Constants.ActivityType.NEWSLETTER_SUBSCRIBED, + }); + } } export async function getSegments(ctx: GQLContext): Promise { diff --git a/apps/web/graphql/users/types.ts b/apps/web/graphql/users/types.ts index 7bb0efa69..92e19728f 100644 --- a/apps/web/graphql/users/types.ts +++ b/apps/web/graphql/users/types.ts @@ -111,7 +111,6 @@ const userType = new GraphQLObjectType({ type: mediaTypes.mediaType, resolve: (user, _, __, ___) => getMedia(user.avatar), }, - invited: { type: GraphQLBoolean }, content: { type: new GraphQLList(userContent) }, }, }); @@ -129,7 +128,6 @@ const userUpdateInput = new GraphQLInputObjectType({ avatar: { type: mediaTypes.mediaInputType, }, - invited: { type: GraphQLBoolean }, }, }); diff --git a/apps/web/jest.client.config.ts b/apps/web/jest.client.config.ts index 3e596729a..d7f57841e 100644 --- a/apps/web/jest.client.config.ts +++ b/apps/web/jest.client.config.ts @@ -33,7 +33,6 @@ const config = { "^react$": "/node_modules/react", "^react-dom$": "/node_modules/react-dom", "^react/jsx-runtime$": "/node_modules/react/jsx-runtime.js", - "next-auth": "/__mocks__/next-auth.ts", "@courselit/utils": "/../../packages/utils/src", "@courselit/common-logic": "/../../packages/common-logic/src", "@courselit/page-primitives": diff --git a/apps/web/jest.server.config.ts b/apps/web/jest.server.config.ts index d3b9cc17c..d7c9f04a2 100644 --- a/apps/web/jest.server.config.ts +++ b/apps/web/jest.server.config.ts @@ -5,15 +5,16 @@ const config: Config = { setupFilesAfterEnv: ["/setupTests.server.ts"], watchPathIgnorePatterns: ["globalConfig"], moduleNameMapper: { - "next-auth": "/__mocks__/next-auth.ts", "@courselit/utils": "/../../packages/utils/src", "@courselit/common-logic": "/../../packages/common-logic/src", "@courselit/page-primitives": "/../../packages/page-primitives/src", nanoid: "/__mocks__/nanoid.ts", + "better-auth": "/__mocks__/better-auth.ts", slugify: "/__mocks__/slugify.ts", "@models/(.*)": "/models/$1", "@/auth": "/auth.ts", + "@/ba-multitenant-adapter": "/ba-multitenant-adapter", "@/payments-new": "/payments-new", "@/graphql/(.*)": "/graphql/$1", "@/config/(.*)": "/config/$1", diff --git a/apps/web/lib/auth-client.ts b/apps/web/lib/auth-client.ts new file mode 100644 index 000000000..73e15bf7c --- /dev/null +++ b/apps/web/lib/auth-client.ts @@ -0,0 +1,6 @@ +import { createAuthClient } from "better-auth/client"; +import { emailOTPClient } from "better-auth/client/plugins"; + +export const authClient = createAuthClient({ + plugins: [emailOTPClient()], +}); diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts index f488bbcb3..599506dd6 100644 --- a/apps/web/lib/utils.ts +++ b/apps/web/lib/utils.ts @@ -1,5 +1,5 @@ import { UIConstants } from "@courselit/common-models"; -import { createHash, randomInt } from "crypto"; +import { randomInt } from "crypto"; export const capitalize = (s: string) => { if (typeof s !== "string") return ""; @@ -66,10 +66,3 @@ export const hasPermissionToAccessSetupChecklist = ( export function generateUniquePasscode() { return randomInt(100000, 999999); } - -// Inspired from: https://github.com/nextauthjs/next-auth/blob/c4ad77b86762b7fd2e6362d8bf26c5953846774a/packages/next-auth/src/core/lib/utils.ts#L16 -export function hashCode(code: number) { - return createHash("sha256") - .update(`${code}${process.env.AUTH_SECRET}`) - .digest("hex"); -} diff --git a/apps/web/models/VerificationToken.ts b/apps/web/models/VerificationToken.ts deleted file mode 100644 index fb4a9ee1e..000000000 --- a/apps/web/models/VerificationToken.ts +++ /dev/null @@ -1,28 +0,0 @@ -import mongoose from "mongoose"; - -export interface VerificationToken { - _id: mongoose.Types.ObjectId; - email: string; - domain: string; - code: string; - timestamp: Date; -} - -const VerificationTokenSchema = new mongoose.Schema({ - email: { type: String, required: true }, - domain: { type: String, required: true }, - code: { type: String, required: true }, - timestamp: { type: Date, required: true }, -}); - -VerificationTokenSchema.index( - { - email: 1, - domain: 1, - code: 1, - }, - { unique: true }, -); - -export default mongoose.models.VerificationToken || - mongoose.model("VerificationToken", VerificationTokenSchema); diff --git a/apps/web/package.json b/apps/web/package.json index 84737df5a..707ecb476 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,11 +13,11 @@ "@courselit/common-models": "workspace:^", "@courselit/components-library": "workspace:^", "@courselit/email-editor": "workspace:^", - "@courselit/text-editor": "workspace:^", "@courselit/icons": "workspace:^", "@courselit/page-blocks": "workspace:^", "@courselit/page-models": "workspace:^", "@courselit/page-primitives": "workspace:^", + "@courselit/text-editor": "workspace:^", "@courselit/utils": "workspace:^", "@hookform/resolvers": "^3.9.1", "@radix-ui/react-alert-dialog": "^1.1.11", @@ -47,6 +47,7 @@ "archiver": "^5.3.1", "aws4": "^1.13.2", "base-64": "^1.0.0", + "better-auth": "^1.4.1", "chart.js": "^4.4.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -61,8 +62,7 @@ "medialit": "^0.1.0", "mongodb": "^6.15.0", "mongoose": "^8.13.1", - "next": "16.0.3", - "next-auth": "^5.0.0-beta.29", + "next": "^16.0.3", "next-themes": "^0.4.6", "nodemailer": "^6.7.2", "pug": "^3.0.2", diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 89f20c4cf..d65ef924c 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -1,11 +1,8 @@ import { NextResponse, type NextRequest } from "next/server"; -import NextAuth from "next-auth"; -import { authConfig } from "./auth.config"; import { getBackendAddress } from "@/app/actions"; +import { auth } from "./auth"; -const { auth } = NextAuth(authConfig); - -export default auth(async (request: NextRequest) => { +export async function proxy(request: NextRequest) { const requestHeaders = request.headers; const backend = await getBackendAddress(requestHeaders); @@ -23,6 +20,13 @@ export default auth(async (request: NextRequest) => { const resp = await response.json(); requestHeaders.set("domain", resp.domain); + requestHeaders.set("domainId", resp.domainId); + requestHeaders.set("domainEmail", resp.domainEmail); + requestHeaders.set("domainTitle", resp.domainTitle || ""); + requestHeaders.set( + "hideCourseLitBranding", + resp.hideCourseLitBranding || false, + ); if (request.nextUrl.pathname === "/favicon.ico") { try { @@ -53,7 +57,9 @@ export default auth(async (request: NextRequest) => { } if (request.nextUrl.pathname.startsWith("/dashboard")) { - const session = await auth(); + const session = await auth.api.getSession({ + headers: requestHeaders, + }); if (!session) { return NextResponse.redirect( new URL( @@ -77,7 +83,7 @@ export default auth(async (request: NextRequest) => { { status: 404 }, ); } -}); +} export const config = { matcher: [ @@ -87,5 +93,4 @@ export const config = { "/healthy", "/dashboard/:path*", ], - unstable_allowDynamic: ["/node_modules/next-auth/**"], }; diff --git a/apps/web/ui-config/strings.ts b/apps/web/ui-config/strings.ts index 75399f682..2a1ab5a11 100644 --- a/apps/web/ui-config/strings.ts +++ b/apps/web/ui-config/strings.ts @@ -162,7 +162,8 @@ export const LOGIN_FORM_LABEL = "Enter your email to sign in or create an account"; export const LOGIN_NO_CODE = "Did not get the code?"; export const BTN_LOGIN_GET_CODE = "Get code"; -export const BTN_LOGIN_CODE_INTIMATION = "Enter the code sent to"; +export const LOGIN_CODE_INTIMATION_MESSAGE = + "Enter the code sent to your email"; export const LOGIN_FORM_DISCLAIMER = "By submitting, you accept the "; export const SIGNUP_SECTION_HEADER = "Create an account"; export const SIGNUP_SECTION_BUTTON = "Join"; @@ -384,9 +385,7 @@ export const MEDIA_PUBLIC = "Publicly available"; export const MEDIA_DIRECT_URL = "Direct URL"; export const MEDIA_URL_COPIED = "Copied to clipboard"; export const MEDIA_FILE_TYPE = "File type"; -export const UNABLE_TO_LOGOUT = "Logout failed. Try again."; export const LOGOUT = "Logout"; -export const LOGGING_OUT = "Logging out..."; export const LOGOUT_MESSAGE = "Are you sure you want to logout?"; export const USER_TABLE_HEADER_NAME = "Details"; export const USER_TABLE_HEADER_STATUS = "Status"; @@ -691,3 +690,5 @@ export const PRODUCTS_LIST_EMPTY_DESCRIPTION_PUBLIC = "The team has not added any products yet."; export const PRODUCTS_LIST_EMPTY_DESCRIPTION_PRIVATE = "You have not added any products yet."; +export const LOGIN_CODE_SENT_MESSAGE = + "We have emailed you a one time password."; diff --git a/deployment/docker/docker-compose.yml b/deployment/docker/docker-compose.yml index 9e10fa7e6..2d1970f4c 100644 --- a/deployment/docker/docker-compose.yml +++ b/deployment/docker/docker-compose.yml @@ -16,8 +16,6 @@ services: # You can use the following command to generate a secure random string. # openssl rand -base64 32 - AUTH_SECRET=som3_rand0m_String - # This prevents the "Host must be trusted" error in next-auth - - AUTH_TRUST_HOST=true # In production, replace the following with the connection string of a cloud # hosted instance of MongoDB. diff --git a/packages/common-logic/src/models/user/index.ts b/packages/common-logic/src/models/user/index.ts index 2d930ba90..3a12c867f 100644 --- a/packages/common-logic/src/models/user/index.ts +++ b/packages/common-logic/src/models/user/index.ts @@ -34,7 +34,6 @@ export const UserSchema = new mongoose.Schema( default: generateUniqueId, }, avatar: MediaSchema, - invited: { type: Boolean }, }, { timestamps: true, diff --git a/packages/common-models/src/user.ts b/packages/common-models/src/user.ts index 34df21e2f..c0a62f30d 100644 --- a/packages/common-models/src/user.ts +++ b/packages/common-models/src/user.ts @@ -16,7 +16,6 @@ export default interface User { lead: (typeof Constants.leads)[number]; tags?: string[]; avatar: Media; - invited?: boolean; content?: { entityType: (typeof Constants.MembershipEntityType)[keyof typeof Constants.MembershipEntityType]; entity: { diff --git a/packages/components-library/src/video-with-preview.tsx b/packages/components-library/src/video-with-preview.tsx index 4323134ac..da437386b 100644 --- a/packages/components-library/src/video-with-preview.tsx +++ b/packages/components-library/src/video-with-preview.tsx @@ -24,7 +24,7 @@ export interface VideoThumbnailProps { export function VideoWithPreview({ title = "Video", thumbnailUrl, - videoUrl = "https://www.youtube.com/watch?v=VLVcZB2-udk", + videoUrl = "https://www.youtube.com/watch?v=7OP2bU9RWVE", aspectRatio = "16/9", modal = false, }: VideoThumbnailProps) { diff --git a/packages/utils/src/fetch-builder.ts b/packages/utils/src/fetch-builder.ts index 3382103e0..f4d97f623 100644 --- a/packages/utils/src/fetch-builder.ts +++ b/packages/utils/src/fetch-builder.ts @@ -56,7 +56,7 @@ class Fetch { window.location.href = options && options.redirectToOnUnAuth ? `/login?redirect=${options.redirectToOnUnAuth}` - : "/logout"; + : "/"; } return {}; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a6225b0f0..65fa2d48f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -312,6 +312,9 @@ importers: base-64: specifier: ^1.0.0 version: 1.0.0 + better-auth: + specifier: ^1.4.1 + version: 1.4.1(next@16.0.3(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) chart.js: specifier: ^4.4.7 version: 4.4.9 @@ -355,11 +358,8 @@ importers: specifier: ^8.13.1 version: 8.14.0(@aws-sdk/credential-providers@3.797.0)(socks@2.8.4) next: - specifier: 16.0.3 + specifier: ^16.0.3 version: 16.0.3(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - next-auth: - specifier: ^5.0.0-beta.29 - version: 5.0.0-beta.29(next@16.0.3(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(nodemailer@6.10.1)(react@19.2.0) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -1266,20 +1266,6 @@ packages: '@astrojs/webapi@1.1.1': resolution: {integrity: sha512-yeUvP27PoiBK/WCxyQzC4HLYZo4Hg6dzRd/dTsL50WGlAQVCwWcqzVJrIZKvzNDNaW/fIXutZTmdj6nec0PIGg==} - '@auth/core@0.40.0': - resolution: {integrity: sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw==} - peerDependencies: - '@simplewebauthn/browser': ^9.0.1 - '@simplewebauthn/server': ^9.0.2 - nodemailer: ^6.8.0 - peerDependenciesMeta: - '@simplewebauthn/browser': - optional: true - '@simplewebauthn/server': - optional: true - nodemailer: - optional: true - '@aws-crypto/sha256-browser@5.2.0': resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} @@ -1591,6 +1577,27 @@ packages: '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + '@better-auth/core@1.4.1': + resolution: {integrity: sha512-N4kyRdA472WGLoCjsJpUeYdZZvpoBDgP65hUeQQxTQYwBTqD9O17Tokax9CdNbkb4g34sTfxaJCfcncE3Hy4SA==} + peerDependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.18 + better-call: 1.1.0 + jose: ^6.1.0 + kysely: ^0.28.5 + nanostores: ^1.0.1 + + '@better-auth/telemetry@1.4.1': + resolution: {integrity: sha512-yNeazXYvMbyuCe1AA6tYWsJEKgcS7gF9PmmACmrPVhVBe1ncDhVfWMZ++YCmA2h8hjkR9755ZyofiYRPbj+kXQ==} + peerDependencies: + '@better-auth/core': 1.4.1 + + '@better-auth/utils@0.3.0': + resolution: {integrity: sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw==} + + '@better-fetch/fetch@1.1.18': + resolution: {integrity: sha512-rEFOE1MYIsBmoMJtQbl32PGHHXuG2hDxvEd7rUHE0vCBoFQVSDqaVs9hkZEtHCxRoY+CljXKFCOuJ8uxqw1LcA==} + '@changesets/apply-release-plan@7.0.13': resolution: {integrity: sha512-BIW7bofD2yAWoE8H4V40FikC+1nNFEKBisMECccS16W1rt6qqhNTBDmIw5HaqmMgtLNz9e7oiALiEUuKrQ4oHg==} @@ -2811,6 +2818,14 @@ packages: cpu: [x64] os: [win32] + '@noble/ciphers@2.0.1': + resolution: {integrity: sha512-xHK3XHPUW8DTAobU+G0XT+/w+JLM7/8k1UFdB5xg/zTFPnFCobhftzw8wl4Lw2aq/Rvir5pxfZV5fEazmeCJ2g==} + engines: {node: '>= 20.19.0'} + + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -2830,9 +2845,6 @@ packages: '@ocavue/svgmoji-cjs@0.1.1': resolution: {integrity: sha512-tCP6ggbtgIL4hPM5goVFSjL51jH/BLl/yBLy98wAV9a2L/Sn9iS3abfprPeQw6/nan5lLaz4Vz8ZP37LKh+xfQ==} - '@panva/hkdf@1.2.1': - resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} - '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -4594,6 +4606,9 @@ packages: resolution: {integrity: sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==} engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.0.0': + resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} + '@stripe/stripe-js@5.10.0': resolution: {integrity: sha512-PTigkxMdMUP6B5ISS7jMqJAKhgrhZwjprDqR1eATtFfh0OpKVNp110xiH+goeVdrJ29/4LeZJR4FaHHWstsu0A==} engines: {node: '>=12.16'} @@ -5622,6 +5637,38 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + better-auth@1.4.1: + resolution: {integrity: sha512-HDVE69Nw6Y1FPTcmFEmPolfsjMfVB5U823Ij9yWBoM8MdHZ2lA3JVus4xQJ2oRE1riJTlcSLFcgJKWGD7V7hmw==} + peerDependencies: + '@lynx-js/react': '*' + '@sveltejs/kit': '*' + next: '*' + react: '*' + react-dom: '*' + solid-js: '*' + svelte: '*' + vue: '*' + peerDependenciesMeta: + '@lynx-js/react': + optional: true + '@sveltejs/kit': + optional: true + next: + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vue: + optional: true + + better-call@1.1.0: + resolution: {integrity: sha512-7CecYG+yN8J1uBJni/Mpjryp8bW/YySYsrGEWgFe048ORASjq17keGjbKI2kHEOSc6u8pi11UxzkJ7jIovQw6w==} + better-path-resolve@1.0.0: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} @@ -6208,6 +6255,9 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + defu@6.1.4: + resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -7927,6 +7977,10 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + kysely@0.28.8: + resolution: {integrity: sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA==} + engines: {node: '>=20.0.0'} + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -8516,6 +8570,10 @@ packages: engines: {node: ^18 || >=20} hasBin: true + nanostores@1.1.0: + resolution: {integrity: sha512-yJBmDJr18xy47dbNVlHcgdPrulSn1nhSE6Ns9vTG+Nx9VPT6iV1MD6aQFp/t52zpf82FhLLTXAXr30NuCnxvwA==} + engines: {node: ^20.0.0 || >=22.0.0} + napi-postinstall@0.1.6: resolution: {integrity: sha512-w1bClprmjwpybo+7M1Rd0N4QK5Ein8kH/1CQ0Wv8Q9vrLbDMakxc4rZpv8zYc8RVErUELJlFhM8UzOF3IqlYKw==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} @@ -8535,22 +8593,6 @@ packages: resolution: {integrity: sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==} engines: {node: '>=12.22.0'} - next-auth@5.0.0-beta.29: - resolution: {integrity: sha512-Ukpnuk3NMc/LiOl32njZPySk7pABEzbjhMUFd5/n10I0ZNC7NCuVv8IY2JgbDek2t/PUOifQEoUiOOTLy4os5A==} - peerDependencies: - '@simplewebauthn/browser': ^9.0.1 - '@simplewebauthn/server': ^9.0.2 - next: ^14.0.0-0 || ^15.0.0-0 - nodemailer: ^6.6.5 - react: ^18.2.0 || ^19.0.0-0 - peerDependenciesMeta: - '@simplewebauthn/browser': - optional: true - '@simplewebauthn/server': - optional: true - nodemailer: - optional: true - next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -8651,9 +8693,6 @@ packages: nwsapi@2.2.20: resolution: {integrity: sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==} - oauth4webapi@3.8.1: - resolution: {integrity: sha512-olkZDELNycOWQf9LrsELFq8n05LwJgV8UkrS0cburk6FOwf8GvLam+YB+Uj5Qvryee+vwWOfQVeI5Vm0MVg7SA==} - object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -8976,14 +9015,6 @@ packages: peerDependencies: preact: '>=10' - preact-render-to-string@6.5.11: - resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} - peerDependencies: - preact: '>=10' - - preact@10.24.3: - resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} - preact@10.26.5: resolution: {integrity: sha512-fmpDkgfGU6JYux9teDWLhj9mKN55tyepwYbxHgQuIxbWQzgFg5vk7Mrrtfx7xRxq798ynkY4DDDxZr235Kk+4w==} @@ -9536,6 +9567,9 @@ packages: rope-sequence@1.3.4: resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} + rou3@0.5.1: + resolution: {integrity: sha512-OXMmJ3zRk2xeXFGfA3K+EOPHC5u7RDFG7lIOx0X1pdnhUkI8MdVrbV+sNsD80ElpUZ+MRHdyxPnFthq9VHs8uQ==} + round-precision@1.0.0: resolution: {integrity: sha512-L2a0XDSNeaaBTEGmzuENMK4T8c0HqKYeS3pCDurW4MRPo8O6LeCLqVPWUt5+xW9rrEcG9QaYrAFcApEFXKziyw==} engines: {node: '>=0.10.0'} @@ -9641,6 +9675,9 @@ packages: resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} engines: {node: '>= 0.8.0'} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -10755,6 +10792,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.1.12: + resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==} + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -11007,16 +11047,6 @@ snapshots: global-agent: 3.0.0 node-fetch: 3.3.2 - '@auth/core@0.40.0(nodemailer@6.10.1)': - dependencies: - '@panva/hkdf': 1.2.1 - jose: 6.1.0 - oauth4webapi: 3.8.1 - preact: 10.24.3 - preact-render-to-string: 6.5.11(preact@10.24.3) - optionalDependencies: - nodemailer: 6.10.1 - '@aws-crypto/sha256-browser@5.2.0': dependencies: '@aws-crypto/sha256-js': 5.2.0 @@ -11649,6 +11679,27 @@ snapshots: '@bcoe/v8-coverage@0.2.3': {} + '@better-auth/core@1.4.1(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.0)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.1.0)': + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.18 + '@standard-schema/spec': 1.0.0 + better-call: 1.1.0 + jose: 6.1.0 + kysely: 0.28.8 + nanostores: 1.1.0 + zod: 4.1.12 + + '@better-auth/telemetry@1.4.1(@better-auth/core@1.4.1(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.0)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.1.0))': + dependencies: + '@better-auth/core': 1.4.1(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.0)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.1.0) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.18 + + '@better-auth/utils@0.3.0': {} + + '@better-fetch/fetch@1.1.18': {} + '@changesets/apply-release-plan@7.0.13': dependencies: '@changesets/config': 3.1.1 @@ -12885,6 +12936,10 @@ snapshots: '@next/swc-win32-x64-msvc@16.0.3': optional: true + '@noble/ciphers@2.0.1': {} + + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -12903,8 +12958,6 @@ snapshots: dependencies: svgmoji: 3.2.0 - '@panva/hkdf@1.2.1': {} - '@pkgjs/parseargs@0.11.0': optional: true @@ -15882,6 +15935,8 @@ snapshots: tslib: 2.8.1 optional: true + '@standard-schema/spec@1.0.0': {} + '@stripe/stripe-js@5.10.0': {} '@svgmoji/blob@3.2.0': @@ -17360,6 +17415,33 @@ snapshots: base64-js@1.5.1: {} + better-auth@1.4.1(next@16.0.3(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + dependencies: + '@better-auth/core': 1.4.1(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.0)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.1.0) + '@better-auth/telemetry': 1.4.1(@better-auth/core@1.4.1(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.1.0)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.1.0)) + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.18 + '@noble/ciphers': 2.0.1 + '@noble/hashes': 2.0.1 + '@standard-schema/spec': 1.0.0 + better-call: 1.1.0 + defu: 6.1.4 + jose: 6.1.0 + kysely: 0.28.8 + nanostores: 1.1.0 + zod: 4.1.12 + optionalDependencies: + next: 16.0.3(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + react: 19.2.0 + react-dom: 19.2.0(react@19.2.0) + + better-call@1.1.0: + dependencies: + '@better-auth/utils': 0.3.0 + '@better-fetch/fetch': 1.1.18 + rou3: 0.5.1 + set-cookie-parser: 2.7.2 + better-path-resolve@1.0.0: dependencies: is-windows: 1.0.2 @@ -17942,6 +18024,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + defu@6.1.4: {} + delayed-stream@1.0.0: {} denque@2.1.0: {} @@ -18356,7 +18440,7 @@ snapshots: '@next/eslint-plugin-next': 16.0.3 eslint: 9.39.1(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@1.21.7)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7)) @@ -18387,7 +18471,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 @@ -18402,14 +18486,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.1(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)) transitivePeerDependencies: - supports-color @@ -18430,7 +18514,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -20223,6 +20307,8 @@ snapshots: kleur@4.1.5: {} + kysely@0.28.8: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -21042,6 +21128,8 @@ snapshots: nanoid@5.1.5: {} + nanostores@1.1.0: {} + napi-postinstall@0.1.6: {} natural-compare@1.4.0: {} @@ -21056,14 +21144,6 @@ snapshots: transitivePeerDependencies: - supports-color - next-auth@5.0.0-beta.29(next@16.0.3(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(nodemailer@6.10.1)(react@19.2.0): - dependencies: - '@auth/core': 0.40.0(nodemailer@6.10.1) - next: 16.0.3(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) - react: 19.2.0 - optionalDependencies: - nodemailer: 6.10.1 - next-themes@0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: react: 19.2.0 @@ -21179,8 +21259,6 @@ snapshots: nwsapi@2.2.20: {} - oauth4webapi@3.8.1: {} - object-assign@4.1.1: {} object-hash@3.0.0: {} @@ -21546,12 +21624,6 @@ snapshots: preact: 10.26.5 pretty-format: 3.8.0 - preact-render-to-string@6.5.11(preact@10.24.3): - dependencies: - preact: 10.24.3 - - preact@10.24.3: {} - preact@10.26.5: {} precision@1.0.1: @@ -22389,6 +22461,8 @@ snapshots: rope-sequence@1.3.4: {} + rou3@0.5.1: {} + round-precision@1.0.0: dependencies: is-finite: 1.0.2 @@ -22506,6 +22580,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -24013,4 +24089,6 @@ snapshots: zod@3.25.76: {} + zod@4.1.12: {} + zwitch@2.0.4: {}