diff --git a/client/package.json b/client/package.json index 387760c0..033cc485 100644 --- a/client/package.json +++ b/client/package.json @@ -40,12 +40,12 @@ "country-iso-3-to-2": "^1.1.1", "d3": "7.9.0", "framer-motion": "11.11.9", + "jose": "^5.9.6", "jotai": "2.10.1", "lodash.isequal": "4.5.0", "lucide-react": "0.447.0", "mapbox-gl": "3.7.0", "next": "14.2.10", - "next-auth": "4.24.8", "nuqs": "2.0.4", "react": "^18", "react-country-flag": "^3.1.0", diff --git a/client/src/app/auth/api/[...nextauth]/config.ts b/client/src/app/auth/api/[...nextauth]/config.ts deleted file mode 100644 index e28316ce..00000000 --- a/client/src/app/auth/api/[...nextauth]/config.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { cookies } from "next/headers"; - -import { UserWithAccessToken } from "@shared/dtos/users/user.dto"; -import { LogInSchema } from "@shared/schemas/auth/login.schema"; -import type { - GetServerSidePropsContext, - NextApiRequest, - NextApiResponse, -} from "next"; -import { getServerSession, NextAuthOptions } from "next-auth"; -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { JWT } from "next-auth/jwt"; -import Credentials from "next-auth/providers/credentials"; - -import { client } from "@/lib/query-client"; - -declare module "next-auth" { - interface Session { - user: UserWithAccessToken["user"]; - accessToken: UserWithAccessToken["accessToken"]; - } - - interface User extends UserWithAccessToken {} -} - -declare module "next-auth/jwt" { - interface JWT { - user: UserWithAccessToken["user"]; - accessToken: UserWithAccessToken["accessToken"]; - } -} - -export const config = { - providers: [ - Credentials({ - // @ts-expect-error - why is so hard to type this? - authorize: async (credentials) => { - let access: UserWithAccessToken | null = null; - - const { email, password } = await LogInSchema.parseAsync(credentials); - const response = await client.auth.login.mutation({ - body: { - email, - password, - }, - }); - - // Check if adminjs was set in the response - const setCookieHeaders = response.headers.get("set-cookie"); - if (setCookieHeaders !== null) { - const [cookieName, cookieValue] = decodeURIComponent(setCookieHeaders) - .split(";")[0] - .split("="); - - const cookieStore = cookies(); - cookieStore.set(cookieName, cookieValue, { - path: "/", - sameSite: "lax", - httpOnly: true, - }); - } - - if (response.status === 201) { - access = response.body; - } - - if (!access) { - if (response.status === 401) { - throw new Error( - response.body.errors?.[0]?.title || "Invalid credentials", - ); - } - } - - return access; - }, - }), - ], - callbacks: { - jwt({ token, user: access, trigger, session }) { - if (access) { - token.user = access.user; - token.accessToken = access.accessToken; - } - - if (trigger === "update") { - token.user.email = session.email; - } - - return token; - }, - session({ session, token }) { - return { - ...session, - user: token.user, - accessToken: token.accessToken, - }; - }, - }, - pages: { - signIn: "/auth/signin", - signOut: "/", - }, -} as NextAuthOptions; - -export function auth( - ...args: - | [GetServerSidePropsContext["req"], GetServerSidePropsContext["res"]] - | [NextApiRequest, NextApiResponse] - | [] -) { - return getServerSession(...args, config); -} diff --git a/client/src/app/auth/api/[...nextauth]/route.ts b/client/src/app/auth/api/[...nextauth]/route.ts deleted file mode 100644 index 6a32c4a6..00000000 --- a/client/src/app/auth/api/[...nextauth]/route.ts +++ /dev/null @@ -1,7 +0,0 @@ -import NextAuth from "next-auth"; - -import { config } from "./config"; - -const handler = NextAuth(config); - -export { handler as GET, handler as POST }; diff --git a/client/src/app/auth/api/session/route.ts b/client/src/app/auth/api/session/route.ts new file mode 100644 index 00000000..b2e07e07 --- /dev/null +++ b/client/src/app/auth/api/session/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; + +import { getServerSession } from "@/lib/auth/server"; +import { AuthApiResponse } from "@/lib/auth/types"; +import { AppSession } from "@/lib/auth/types"; + +export async function GET(): Promise< + NextResponse> +> { + const session = await getServerSession(); + + return NextResponse.json({ + body: session || null, + status: session ? 200 : 401, + }); +} diff --git a/client/src/app/auth/api/signin/route.ts b/client/src/app/auth/api/signin/route.ts new file mode 100644 index 00000000..88b1e331 --- /dev/null +++ b/client/src/app/auth/api/signin/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { generateUserJWT } from "@/lib/auth/jwt"; +import { setAuthCookie, setResponseCookie } from "@/lib/auth/server"; +import { AuthApiResponse, AppSession } from "@/lib/auth/types"; +import { client } from "@/lib/query-client"; + +export async function POST( + req: NextRequest, +): Promise>> { + try { + const { email, password } = await req.json(); + + const response = await client.auth.login.mutation({ + body: { email, password }, + }); + + if (response.status !== 201) { + return NextResponse.json({ + body: null, + status: response.status, + error: response.body.errors?.[0]?.title || "Invalid credentials", + }); + } + + setResponseCookie(response.headers); + + const appSession: AppSession = response.body; + const token = await generateUserJWT(appSession); + setAuthCookie(token); + + return NextResponse.json({ + body: appSession, + status: 201, + }); + } catch (err) { + return NextResponse.json({ + body: null, + status: 500, + error: "An error occurred during sign in", + }); + } +} diff --git a/client/src/app/auth/api/signout/route.ts b/client/src/app/auth/api/signout/route.ts new file mode 100644 index 00000000..353b515c --- /dev/null +++ b/client/src/app/auth/api/signout/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; + +import { revokeSession } from "@/lib/auth/server"; +import { AuthApiResponse } from "@/lib/auth/types"; + +export async function POST(): Promise>> { + await revokeSession(); + + return NextResponse.json({ + body: null, + status: 200, + }); +} diff --git a/client/src/app/auth/signin/page.tsx b/client/src/app/auth/signin/page.tsx index 2c7817dd..2efb75d2 100644 --- a/client/src/app/auth/signin/page.tsx +++ b/client/src/app/auth/signin/page.tsx @@ -2,7 +2,7 @@ import { redirect } from "next/navigation"; import { Metadata } from "next"; -import { auth } from "@/app/auth/api/[...nextauth]/config"; +import { getServerSession } from "@/lib/auth/server"; import SignIn from "@/containers/auth/signin"; import AuthLayout from "@/containers/auth-layout"; @@ -13,7 +13,7 @@ export const metadata: Metadata = { }; export default async function SignInPage() { - const session = await auth(); + const session = await getServerSession(); if (session) { redirect("/profile"); diff --git a/client/src/app/auth/signup/page.tsx b/client/src/app/auth/signup/page.tsx index b76f4dd4..233cc402 100644 --- a/client/src/app/auth/signup/page.tsx +++ b/client/src/app/auth/signup/page.tsx @@ -2,7 +2,7 @@ import { redirect } from "next/navigation"; import { Metadata } from "next"; -import { auth } from "@/app/auth/api/[...nextauth]/config"; +import { getServerSession } from "@/lib/auth/server"; import SignUp from "@/containers/auth/signup"; import AuthLayout from "@/containers/auth-layout"; @@ -13,7 +13,7 @@ export const metadata: Metadata = { }; export default async function SignInPage() { - const session = await auth(); + const session = await getServerSession(); if (session) { redirect("/profile"); diff --git a/client/src/app/layout.tsx b/client/src/app/layout.tsx index e72c9ae2..5a62630f 100644 --- a/client/src/app/layout.tsx +++ b/client/src/app/layout.tsx @@ -4,10 +4,9 @@ import { Spline_Sans } from "next/font/google"; import type { Metadata } from "next"; import "@/app/globals.css"; -import { getServerSession } from "next-auth"; import { NuqsAdapter } from "nuqs/adapters/next/app"; -import { config } from "@/app/auth/api/[...nextauth]/config"; +import { getServerSession } from "@/lib/auth/server"; import IntroModal from "@/containers/intro-modal"; import MainNav from "@/containers/nav"; @@ -49,7 +48,7 @@ export const metadata: Metadata = { export default async function RootLayout({ children, }: Readonly) { - const session = await getServerSession(config); + const session = await getServerSession(); return ( diff --git a/client/src/app/my-projects/page.tsx b/client/src/app/my-projects/page.tsx index 033b7d77..b1852427 100644 --- a/client/src/app/my-projects/page.tsx +++ b/client/src/app/my-projects/page.tsx @@ -4,16 +4,15 @@ import { QueryClient, } from "@tanstack/react-query"; +import { getServerSession } from "@/lib/auth/server"; import { client } from "@/lib/query-client"; import { queryKeys } from "@/lib/query-keys"; -import { auth } from "@/app/auth/api/[...nextauth]/config"; - import MyProjectsView from "@/containers/my-projects"; export default async function MyProjects() { const queryClient = new QueryClient(); - const session = await auth(); + const session = await getServerSession(); await queryClient.prefetchQuery({ queryKey: queryKeys.customProjects.all().queryKey, diff --git a/client/src/app/profile/page.tsx b/client/src/app/profile/page.tsx index fc9672f5..c5122ff0 100644 --- a/client/src/app/profile/page.tsx +++ b/client/src/app/profile/page.tsx @@ -1,19 +1,18 @@ import { QueryClient, dehydrate } from "@tanstack/react-query"; import { HydrationBoundary } from "@tanstack/react-query"; +import { getServerSession } from "@/lib/auth/server"; import { client } from "@/lib/query-client"; import { queryKeys } from "@/lib/query-keys"; -import { auth } from "@/app/auth/api/[...nextauth]/config"; - import Profile from "@/containers/profile"; export default async function ProfilePage() { const queryClient = new QueryClient(); - const session = await auth(); + const session = await getServerSession(); await queryClient.prefetchQuery({ - queryKey: queryKeys.user.me(session?.user?.id as string).queryKey, + queryKey: queryKeys.user.me(session?.user.id as string).queryKey, queryFn: () => client.user.findMe.query({ extraHeaders: { diff --git a/client/src/app/providers.tsx b/client/src/app/providers.tsx index 7c7cb13d..7c52e2eb 100644 --- a/client/src/app/providers.tsx +++ b/client/src/app/providers.tsx @@ -8,12 +8,11 @@ import { QueryClientProvider, } from "@tanstack/react-query"; import { createStore, Provider as JotaiProvider } from "jotai"; -import { Session } from "next-auth"; -import { SessionProvider } from "next-auth/react"; +import { AuthProvider } from "@/lib/auth/context"; +import { AppSession } from "@/lib/auth/types"; import { makeQueryClient } from "@/lib/query-client"; -import SessionChecker from "@/components/session-checker"; import { TooltipProvider } from "@/components/ui/tooltip"; let browserQueryClient: QueryClient | undefined = undefined; @@ -35,24 +34,21 @@ export function getQueryClient() { export default function LayoutProviders({ children, session, -}: PropsWithChildren<{ session: Session | null }>) { +}: PropsWithChildren<{ session: AppSession | null }>) { const queryClient = getQueryClient(); const appStore = createStore(); return ( <> - + - - - {children} - + {children} - + ); } diff --git a/client/src/components/session-checker/index.tsx b/client/src/components/session-checker/index.tsx deleted file mode 100644 index b5d1a2be..00000000 --- a/client/src/components/session-checker/index.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { useEffect, useMemo } from "react"; - -import { usePathname } from "next/navigation"; - -import { TOKEN_TYPE_ENUM } from "@shared/schemas/auth/token-type.schema"; -import { signOut, useSession } from "next-auth/react"; - -import { client } from "@/lib/query-client"; -import { queryKeys } from "@/lib/query-keys"; -import { isPrivatePath } from "@/lib/utils"; - -/** - * This should be a temporary solution to check if the access token is expired. - * next-auth currently does not support server-side signout. - * More info https://github.com/nextauthjs/next-auth/discussions/5334 - * TODO: A better solution would be to use a middleware to check if the access token is expired! - */ -export default function SessionChecker() { - const { data: session } = useSession(); - const queryKey = queryKeys.auth.validateToken(session?.accessToken).queryKey; - - const pathname = usePathname(); - const queryEnabled = useMemo( - () => isPrivatePath(pathname) && !!session?.accessToken, - [session?.accessToken, pathname], - ); - const { error } = client.auth.validateToken.useQuery( - queryKey, - { - query: { - tokenType: TOKEN_TYPE_ENUM.ACCESS, - }, - headers: { - authorization: `Bearer ${session?.accessToken}`, - }, - }, - { - queryKey, - enabled: queryEnabled, - }, - ); - - useEffect(() => { - if (error && queryEnabled) { - signOut({ - redirect: true, - callbackUrl: queryEnabled ? "/auth/signin" : undefined, - }); - } - }, [error, pathname, queryEnabled]); - - return null; -} diff --git a/client/src/containers/auth/signin/form/index.tsx b/client/src/containers/auth/signin/form/index.tsx index f68f695d..aca288c7 100644 --- a/client/src/containers/auth/signin/form/index.tsx +++ b/client/src/containers/auth/signin/form/index.tsx @@ -9,9 +9,10 @@ import { useSearchParams, useRouter } from "next/navigation"; import { zodResolver } from "@hookform/resolvers/zod"; import { LogInSchema } from "@shared/schemas/auth/login.schema"; -import { signIn } from "next-auth/react"; import { z } from "zod"; +import { useAuth } from "@/lib/auth/context"; + import EmailInput from "@/containers/auth/email-input"; import { Button } from "@/components/ui/button"; @@ -29,6 +30,7 @@ interface SignInFormProps { onSignIn?: () => void; } const SignInForm: FC = ({ onSignIn }) => { + const { login } = useAuth(); const router = useRouter(); const searchParams = useSearchParams(); const [errorMessage, setErrorMessage] = useState(""); @@ -36,7 +38,7 @@ const SignInForm: FC = ({ onSignIn }) => { const form = useForm>({ resolver: zodResolver(LogInSchema), defaultValues: { - email: "", + email: "adam.trincas@vizzuality.com", password: "", }, }); @@ -48,30 +50,20 @@ const SignInForm: FC = ({ onSignIn }) => { form.handleSubmit(async (formValues) => { try { - const response = await signIn("credentials", { - ...formValues, - redirect: false, - }); - - if (response?.ok) { - if (onSignIn) { - onSignIn(); - } else { - router.push(searchParams.get("callbackUrl") ?? "/profile"); - } - } - - if (!response?.ok) { - setErrorMessage(response?.error ?? "unknown error"); + await login(formValues.email, formValues.password); + if (onSignIn) { + onSignIn(); + } else { + router.push(searchParams.get("callbackUrl") ?? "/profile"); } - } catch (err) { - if (err instanceof Error) { - setErrorMessage(err.message ?? "unknown error"); + } catch (error) { + if (error instanceof Error) { + setErrorMessage(error.message ?? "unknown error"); } } })(evt); }, - [form, router, searchParams, onSignIn], + [form, router, searchParams, login, onSignIn], ); return ( diff --git a/client/src/containers/my-projects/columns.tsx b/client/src/containers/my-projects/columns.tsx index 2d42fa6d..7ed4d45e 100644 --- a/client/src/containers/my-projects/columns.tsx +++ b/client/src/containers/my-projects/columns.tsx @@ -11,8 +11,8 @@ import { ACTIVITY } from "@shared/entities/activity.enum"; import { CustomProject as CustomProjectEntity } from "@shared/entities/custom-project.entity"; import { useQueryClient } from "@tanstack/react-query"; import { Table as TableInstance, Row, ColumnDef } from "@tanstack/react-table"; -import { useSession } from "next-auth/react"; +import { useAuth } from "@/lib/auth/context"; import { formatCurrency } from "@/lib/format"; import { client } from "@/lib/query-client"; import { cn, getAuthHeader } from "@/lib/utils"; @@ -46,7 +46,7 @@ const ActionsDropdown = ({ const { "update-selection": updateSelection } = useFeatureFlags(); const isHeader = "getSelectedRowModel" in instance; const deleteLabel = isHeader ? "Delete selection" : "Delete project"; - const { data: session } = useSession(); + const { session } = useAuth(); const { toast } = useToast(); const queryClient = useQueryClient(); const deleteCustomProjects = useCallback( diff --git a/client/src/containers/my-projects/index.tsx b/client/src/containers/my-projects/index.tsx index 491b0c71..d8ba579b 100644 --- a/client/src/containers/my-projects/index.tsx +++ b/client/src/containers/my-projects/index.tsx @@ -15,8 +15,8 @@ import { } from "@tanstack/react-table"; import { motion } from "framer-motion"; import { ChevronsUpDownIcon } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { useAuth } from "@/lib/auth/context"; import { client } from "@/lib/query-client"; import { queryKeys } from "@/lib/query-keys"; import { cn, getAuthHeader } from "@/lib/utils"; @@ -49,7 +49,7 @@ const LAYOUT_TRANSITIONS = { }; export default function MyProjectsView() { - const { data: session } = useSession(); + const { session } = useAuth(); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: Number.parseInt(PAGINATION_SIZE_OPTIONS[0]), diff --git a/client/src/containers/nav/index.tsx b/client/src/containers/nav/index.tsx index f3bcea33..45136da2 100644 --- a/client/src/containers/nav/index.tsx +++ b/client/src/containers/nav/index.tsx @@ -15,9 +15,10 @@ import { ServerCogIcon, UserIcon, } from "lucide-react"; -import { useSession } from "next-auth/react"; import { useMediaQuery } from "usehooks-ts"; +import { useAuth } from "@/lib/auth/context"; +import { AuthStatus } from "@/lib/auth/types"; import { cn, getThemeSize } from "@/lib/utils"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; @@ -76,9 +77,9 @@ const navItems = { export default function MainNav() { const { open } = useSidebar(); - const { status, data } = useSession(); + const { status, session } = useAuth(); const pathname = usePathname(); - const isAdmin = data?.user.role === ROLES.ADMIN; + const isAdmin = session?.user.role === ROLES.ADMIN; const { "methodology-page": methodologyPage } = useFeatureFlags(); const mainNavItems = useMemo( () => @@ -87,7 +88,7 @@ export default function MainNav() { return isAdmin; } if (item.isAuth) { - return status === "authenticated"; + return status === AuthStatus.AUTHENTICATED; } return true; }), diff --git a/client/src/containers/profile/account-details/index.tsx b/client/src/containers/profile/account-details/index.tsx index a4618a18..12b7d187 100644 --- a/client/src/containers/profile/account-details/index.tsx +++ b/client/src/containers/profile/account-details/index.tsx @@ -6,9 +6,9 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQueryClient } from "@tanstack/react-query"; -import { useSession } from "next-auth/react"; import { z } from "zod"; +import { useAuth } from "@/lib/auth/context"; import { client } from "@/lib/query-client"; import { queryKeys } from "@/lib/query-keys"; @@ -28,7 +28,7 @@ import { accountDetailsSchema } from "./schema"; const AccountDetailsForm: FC = () => { const queryClient = useQueryClient(); - const { data: session, update: updateSession } = useSession(); + const { session } = useAuth(); const formRef = useRef(null); const { toast } = useToast(); @@ -73,7 +73,7 @@ const AccountDetailsForm: FC = () => { }); if (response.status === 200) { - await updateSession(response.body); + // await updateSession(response.body); await queryClient.invalidateQueries({ queryKey: queryKeys.user.me(session?.user?.id as string).queryKey, @@ -85,7 +85,7 @@ const AccountDetailsForm: FC = () => { } } }, - [queryClient, session, updateSession, toast], + [queryClient, session, toast], ); const handleEnterKey = useCallback( diff --git a/client/src/containers/profile/custom-projects/index.tsx b/client/src/containers/profile/custom-projects/index.tsx index 20ab6d35..46141b81 100644 --- a/client/src/containers/profile/custom-projects/index.tsx +++ b/client/src/containers/profile/custom-projects/index.tsx @@ -1,8 +1,8 @@ import { FC, useMemo } from "react"; import { ACTIVITY } from "@shared/entities/activity.enum"; -import { useSession } from "next-auth/react"; +import { useAuth } from "@/lib/auth/context"; import { client } from "@/lib/query-client"; import { getAuthHeader } from "@/lib/utils"; @@ -18,7 +18,7 @@ import { } from "@/components/ui/table"; const CustomProjects: FC = () => { - const { data: session } = useSession(); + const { session } = useAuth(); const queryKey = DEFAULT_CUSTOM_PROJECTS_QUERY_KEY; const { data: projects } = client.customProjects.getCustomProjects.useQuery( queryKey, diff --git a/client/src/containers/profile/delete-account/index.tsx b/client/src/containers/profile/delete-account/index.tsx index ca5e4d59..ec8dc83e 100644 --- a/client/src/containers/profile/delete-account/index.tsx +++ b/client/src/containers/profile/delete-account/index.tsx @@ -2,8 +2,10 @@ import { FC, useCallback } from "react"; -import { signOut, useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import { signOut } from "@/lib/auth/api"; +import { useAuth } from "@/lib/auth/context"; import { client } from "@/lib/query-client"; import { @@ -21,9 +23,9 @@ import { Button } from "@/components/ui/button"; import { useToast } from "@/components/ui/toast/use-toast"; const DeleteAccount: FC = () => { - const { data: session } = useSession(); + const { session } = useAuth(); const { toast } = useToast(); - + const router = useRouter(); const onDeleteAccount = useCallback(async () => { try { const { status, body } = await client.user.deleteMe.mutation({ @@ -33,7 +35,8 @@ const DeleteAccount: FC = () => { }); if (status === 200) { - signOut({ callbackUrl: "/auth/signin", redirect: true }); + await signOut(); + router.push("/auth/signin"); } else if (status === 400 || status === 401) { toast({ variant: "destructive", @@ -46,7 +49,7 @@ const DeleteAccount: FC = () => { description: "Something went wrong deleting the account.", }); } - }, [session?.accessToken, toast]); + }, [session?.accessToken, toast, router]); return ( diff --git a/client/src/containers/profile/edit-password/form/index.tsx b/client/src/containers/profile/edit-password/form/index.tsx index aab9f53c..ec672319 100644 --- a/client/src/containers/profile/edit-password/form/index.tsx +++ b/client/src/containers/profile/edit-password/form/index.tsx @@ -5,9 +5,9 @@ import { FC, useCallback, useRef } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; -import { useSession } from "next-auth/react"; import { z } from "zod"; +import { useAuth } from "@/lib/auth/context"; import { client } from "@/lib/query-client"; import { Button } from "@/components/ui/button"; @@ -27,7 +27,7 @@ import { changePasswordSchema } from "./schema"; const SignUpForm: FC = () => { const formRef = useRef(null); - const { data: session } = useSession(); + const { session } = useAuth(); const { toast } = useToast(); const form = useForm>({ diff --git a/client/src/containers/profile/file-upload/index.tsx b/client/src/containers/profile/file-upload/index.tsx index 39dac29a..a01b3692 100644 --- a/client/src/containers/profile/file-upload/index.tsx +++ b/client/src/containers/profile/file-upload/index.tsx @@ -3,8 +3,8 @@ import React, { FC, useCallback, useState } from "react"; import { useDropzone } from "react-dropzone"; import { FileUpIcon, XIcon } from "lucide-react"; -import { useSession } from "next-auth/react"; +import { useAuth } from "@/lib/auth/context"; import { client } from "@/lib/query-client"; import { cn } from "@/lib/utils"; @@ -30,7 +30,7 @@ const MAX_FILES = 2; const FileUpload: FC = () => { const [files, setFiles] = useState([]); - const { data: session } = useSession(); + const { session } = useAuth(); const { toast } = useToast(); const onDropAccepted = useCallback( (acceptedFiles: File[]) => { diff --git a/client/src/containers/profile/profile-sidebar/index.tsx b/client/src/containers/profile/profile-sidebar/index.tsx index 2bd6ac64..7d224c8a 100644 --- a/client/src/containers/profile/profile-sidebar/index.tsx +++ b/client/src/containers/profile/profile-sidebar/index.tsx @@ -4,8 +4,8 @@ import Link from "next/link"; import { useAtomValue } from "jotai"; import { LogOutIcon } from "lucide-react"; -import { signOut, useSession } from "next-auth/react"; +import { useAuth } from "@/lib/auth/context"; import { client } from "@/lib/query-client"; import { queryKeys } from "@/lib/query-keys"; @@ -31,7 +31,7 @@ interface ProfileSidebarProps { navItems: { name: string; id: string }[]; } const ProfileSidebar: FC = ({ navItems }) => { - const { data: session } = useSession(); + const { session, logout } = useAuth(); const { data: user } = client.user.findMe.useQuery( queryKeys.user.me(session?.user?.id as string).queryKey, { @@ -86,7 +86,7 @@ const ProfileSidebar: FC = ({ navItems }) => { variant="outline" className="w-full font-bold" onClick={async () => { - await signOut(); + await logout(); }} > diff --git a/client/src/containers/profile/update-email/index.tsx b/client/src/containers/profile/update-email/index.tsx index 166f9d22..a89cb3e8 100644 --- a/client/src/containers/profile/update-email/index.tsx +++ b/client/src/containers/profile/update-email/index.tsx @@ -6,9 +6,9 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useQueryClient } from "@tanstack/react-query"; -import { useSession } from "next-auth/react"; import { z } from "zod"; +import { useAuth } from "@/lib/auth/context"; import { client } from "@/lib/query-client"; import { queryKeys } from "@/lib/query-keys"; @@ -28,7 +28,7 @@ import { accountDetailsSchema } from "./schema"; const UpdateEmailForm: FC = () => { const queryClient = useQueryClient(); - const { data: session, update: updateSession } = useSession(); + const { session } = useAuth(); const formRef = useRef(null); const { toast } = useToast(); @@ -70,7 +70,7 @@ const UpdateEmailForm: FC = () => { }); if (response.status === 200) { - updateSession(response.body); + // updateSession(response.body); queryClient.invalidateQueries({ queryKey: queryKeys.user.me(session?.user?.id as string).queryKey, @@ -93,7 +93,7 @@ const UpdateEmailForm: FC = () => { } } }, - [queryClient, session, updateSession, toast], + [queryClient, session, toast], ); const handleEnterKey = useCallback( diff --git a/client/src/containers/projects/custom-project/header/index.tsx b/client/src/containers/projects/custom-project/header/index.tsx index 5ab7be9c..fd28ec9d 100644 --- a/client/src/containers/projects/custom-project/header/index.tsx +++ b/client/src/containers/projects/custom-project/header/index.tsx @@ -6,9 +6,10 @@ import { CustomProject as CustomProjectEntity } from "@shared/entities/custom-pr import { useQueryClient } from "@tanstack/react-query"; import { useAtom } from "jotai"; import { LayoutListIcon } from "lucide-react"; -import { Session } from "next-auth"; -import { getSession, useSession } from "next-auth/react"; +import { getSession } from "@/lib/auth/api"; +import { useAuth } from "@/lib/auth/context"; +import { AppSession } from "@/lib/auth/types"; import { client } from "@/lib/query-client"; import { cn, getAuthHeader } from "@/lib/utils"; @@ -29,11 +30,11 @@ const CustomProjectHeader: FC = ({ data }) => { const [{ projectSummaryOpen }, setProjectSummaryOpen] = useAtom(projectsUIState); const queryClient = useQueryClient(); - const { data: session } = useSession(); + const { session } = useAuth(); const { toast } = useToast(); const [saved, setSaved] = useState(false); const SaveProject = useCallback( - async (arg: Session | null = session) => { + async (arg: AppSession | null = session) => { try { const { status, body } = await client.customProjects.saveCustomProject.mutation({ diff --git a/client/src/hooks/use-get-custom-project.ts b/client/src/hooks/use-get-custom-project.ts index cd75a1e4..2e5ad7bc 100644 --- a/client/src/hooks/use-get-custom-project.ts +++ b/client/src/hooks/use-get-custom-project.ts @@ -4,14 +4,14 @@ import { usePathname, useRouter } from "next/navigation"; import { CustomProject as CustomProjectEntity } from "@shared/entities/custom-project.entity"; import { useQueryClient } from "@tanstack/react-query"; -import { useSession } from "next-auth/react"; +import { useAuth } from "@/lib/auth/context"; import { client } from "@/lib/query-client"; import { queryKeys } from "@/lib/query-keys"; import { getAuthHeader } from "@/lib/utils"; export function useGetCustomProject(id?: string) { - const { data: session } = useSession(); + const { session } = useAuth(); const queryCache = useQueryClient().getQueryData<{ data: InstanceType; }>(queryKeys.customProjects.cached.queryKey); diff --git a/client/src/lib/auth/api.ts b/client/src/lib/auth/api.ts new file mode 100644 index 00000000..e3c3db8b --- /dev/null +++ b/client/src/lib/auth/api.ts @@ -0,0 +1,78 @@ +import { UserWithAccessToken } from "@shared/dtos/users/user.dto"; + +import { AppSession, AuthApiResponse } from "@/lib/auth/types"; + +import { getServerAuthUrl } from "./server"; + +/** + * Determines the auth API URL based on the execution environment. + * Returns an absolute URL when running server-side, and a relative path for client-side. + * @returns {string} The auth API URL + */ +async function getAuthUrl(): Promise { + const path = "auth/api"; + + if (typeof window === "undefined") { + return await getServerAuthUrl(); + } + + return `/${path}`; +} + +/** + * Authenticates a user by sending a POST request to the signin endpoint. + * @param {string} email - The user's email address + * @param {string} password - The user's password + * @returns {Promise} The authenticated user data with access token + * @throws Will throw an error if the authentication fails + */ +export async function signIn( + email: string, + password: string, +): Promise { + try { + const baseUrl = await getAuthUrl(); + const response = await fetch(`${baseUrl}/signin`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }), + }); + + const data: AuthApiResponse = await response.json(); + + if (data.status !== 201) { + throw new Error(data.error || "Invalid credentials"); + } + + return data.body; + } catch (error) { + throw error; + } +} + +/** + * Signs out the current user by making a POST request to the signout endpoint. + * @returns {Promise} + * TODO: nice to have: { callbackUrl: string; redirect: boolean } + */ +export async function signOut(): Promise { + try { + const baseUrl = await getAuthUrl(); + await fetch(`${baseUrl}/signout`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + }); + } catch (error) { + console.error("Error signing out", error); + } +} + +export async function getSession(): Promise { + const baseUrl = await getAuthUrl(); + const response = await fetch(`${baseUrl}/session`); + return response.json(); +} diff --git a/client/src/lib/auth/constants.ts b/client/src/lib/auth/constants.ts new file mode 100644 index 00000000..e723181f --- /dev/null +++ b/client/src/lib/auth/constants.ts @@ -0,0 +1,5 @@ +import { TOKEN_TYPE_ENUM } from "@shared/schemas/auth/token-type.schema"; + +export const TOKEN_KEY = TOKEN_TYPE_ENUM.ACCESS; +export const SIGNIN_PATH = "/auth/signin?expired=true"; +export const JWT_SECRET = process.env.NEXTAUTH_SECRET!; diff --git a/client/src/lib/auth/context.tsx b/client/src/lib/auth/context.tsx new file mode 100644 index 00000000..85691bc8 --- /dev/null +++ b/client/src/lib/auth/context.tsx @@ -0,0 +1,72 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from "react"; + +import { useRouter, useSearchParams } from "next/navigation"; + +import { signIn, signOut } from "@/lib/auth/api"; + +import { AppSession, AuthContextType, AuthStatus } from "./types"; + +const AuthContext = createContext(undefined); + +interface AuthProviderProps { + children: React.ReactNode; + initialSession: AppSession | null; +} + +export function AuthProvider({ children, initialSession }: AuthProviderProps) { + const [session, setSession] = useState(initialSession); + const [status, setStatus] = useState(AuthStatus.LOADING); + const router = useRouter(); + const searchParams = useSearchParams(); + const resetSession = useCallback((callback?: () => void) => { + setSession(null); + setStatus(AuthStatus.UNAUTHENTICATED); + callback?.(); + }, []); + + useEffect(() => { + if (initialSession) { + setStatus(AuthStatus.AUTHENTICATED); + } else { + setStatus(AuthStatus.UNAUTHENTICATED); + } + }, [initialSession]); + + useEffect(() => { + // Check if we were redirected due to session expiry + if (searchParams.get("expired") === "true") { + resetSession(); + } + }, [searchParams, resetSession]); + + const login = async (email: string, password: string) => { + const newSession = await signIn(email, password); + setSession(newSession); + setStatus(AuthStatus.AUTHENTICATED); + }; + + const logout = async () => { + await signOut(); + resetSession(() => router.push("/auth/signin")); + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/client/src/lib/auth/jwt.ts b/client/src/lib/auth/jwt.ts new file mode 100644 index 00000000..7bd0239a --- /dev/null +++ b/client/src/lib/auth/jwt.ts @@ -0,0 +1,71 @@ +import { UserDto } from "@shared/dtos/users/user.dto"; +import { SignJWT } from "jose"; +import { jwtVerify } from "jose"; + +import { AppSession } from "@/lib/auth/types"; +import { client } from "@/lib/query-client"; + +import { JWT_SECRET, TOKEN_KEY } from "./constants"; + +/** + * Validates an authentication token by making a request to the auth service + * @param token - The bearer token to validate + * @returns Promise that resolves to true if the token is valid, false otherwise + */ +export async function validateAccessToken(token?: string): Promise { + if (!token) return false; + + try { + const response = await client.auth.validateToken.query({ + query: { tokenType: TOKEN_KEY }, + headers: { authorization: `Bearer ${token}` }, + }); + + return response.status === 200; + } catch { + return false; + } +} + +/** + * Generates a signed JWT containing user information and access token + * @param payload - Object containing user data and access token + * @param payload.user - User data transfer object + * @param payload.accessToken - Access token to include in the JWT + * @returns Promise that resolves to the signed JWT string + */ +export async function generateUserJWT(payload: { + user: UserDto; + accessToken: string; +}): Promise { + const secret = new TextEncoder().encode(JWT_SECRET); + return new SignJWT(payload) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("30d") + .sign(secret); +} + +/** + * Verifies and decodes a JWT token + * @param token - The JWT token to verify + * @returns Promise resolving to decoded payload if valid, null otherwise + */ +export async function verifyUserJWT(token: string): Promise { + try { + const secret = new TextEncoder().encode(JWT_SECRET); + const { payload } = await jwtVerify(token, secret); + + const isValid = await validateAccessToken(payload.accessToken as string); + + if (!isValid) { + return null; + } + + return { + user: payload.user as UserDto, + accessToken: payload.accessToken as string, + }; + } catch (err) { + return null; + } +} diff --git a/client/src/lib/auth/server.ts b/client/src/lib/auth/server.ts new file mode 100644 index 00000000..6ce4e9e6 --- /dev/null +++ b/client/src/lib/auth/server.ts @@ -0,0 +1,69 @@ +"use server"; + +import { headers, cookies } from "next/headers"; + +import { TOKEN_KEY } from "@/lib/auth/constants"; +import { verifyUserJWT } from "@/lib/auth/jwt"; +import { AppSession } from "@/lib/auth/types"; + +/** + * Retrieves and validates the current server session + * @returns Promise resolving to AppSession if valid session exists, null otherwise + */ +export async function getServerSession(): Promise { + const cookieStore = cookies(); + const token = cookieStore.get(TOKEN_KEY); + + if (!token) return null; + + return await verifyUserJWT(token.value); +} + +/** + * Sets the authentication cookie with the provided token + * @param token - JWT token string to be stored in cookies + * @returns Promise + */ +export async function setAuthCookie(token: string): Promise { + cookies().set(TOKEN_KEY, token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + }); +} + +/** + * Removes the authentication cookie, effectively ending the session + * @returns Promise + */ +export async function revokeSession(): Promise { + cookies().delete(TOKEN_KEY); +} + +export async function getServerAuthUrl(): Promise { + const host = headers().get("host"); + const protocol = process.env.NODE_ENV === "development" ? "http" : "https"; + return `${protocol}://${host}/auth/api`; +} + +/** + * Sets a cookie from response headers + * @param response - Response object containing set-cookie header + * @returns Promise + */ +export async function setResponseCookie(headers: Headers): Promise { + const setCookieHeaders = headers.get("set-cookie"); + if (setCookieHeaders !== null) { + const [cookieName, cookieValue] = decodeURIComponent(setCookieHeaders) + .split(";")[0] + .split("="); + + const cookieStore = cookies(); + cookieStore.set(cookieName, cookieValue, { + path: "/", + sameSite: "lax", + httpOnly: true, + }); + } +} diff --git a/client/src/lib/auth/types.ts b/client/src/lib/auth/types.ts new file mode 100644 index 00000000..30380e02 --- /dev/null +++ b/client/src/lib/auth/types.ts @@ -0,0 +1,25 @@ +import { UserWithAccessToken } from "@shared/dtos/users/user.dto"; + +export interface AppSession { + user: UserWithAccessToken["user"]; + accessToken: UserWithAccessToken["accessToken"]; +} + +export interface AuthContextType { + session: AppSession | null; + status: AuthStatus; + login: (email: string, password: string) => Promise; + logout: () => Promise; +} + +export enum AuthStatus { + LOADING = "loading", + UNAUTHENTICATED = "unauthenticated", + AUTHENTICATED = "authenticated", +} + +export interface AuthApiResponse { + body: T; + status: number; + error?: string; +} diff --git a/client/src/middleware.ts b/client/src/middleware.ts index 022e6060..2a5aa4d7 100644 --- a/client/src/middleware.ts +++ b/client/src/middleware.ts @@ -1,17 +1,32 @@ -import { NextResponse } from "next/server"; - -import { NextRequestWithAuth, withAuth } from "next-auth/middleware"; +import { NextRequest, NextResponse } from "next/server"; +import { signOut } from "@/lib/auth/api"; +import { SIGNIN_PATH } from "@/lib/auth/constants"; +import { getServerSession } from "@/lib/auth/server"; import { isPrivatePath } from "@/lib/utils"; -export default function middleware(req: NextRequestWithAuth) { +export async function middleware(req: NextRequest) { if (isPrivatePath(req.nextUrl.pathname)) { - return withAuth(req, { - pages: { - signIn: "/auth/signin", - }, - }); + const session = await getServerSession(); + + if (!session) { + await signOut(); + return NextResponse.redirect(new URL(SIGNIN_PATH, req.url)); + } } return NextResponse.next(); } + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - api (API routes) + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + */ + "/((?!api|_next/static|_next/image|favicon.ico).*)", + ], +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81d81522..062f1f02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,27 +12,6 @@ catalogs: '@types/node': specifier: 20.14.2 version: 20.14.2 - bcrypt: - specifier: 5.1.1 - version: 5.1.1 - class-transformer: - specifier: 0.5.1 - version: 0.5.1 - class-validator: - specifier: 0.14.1 - version: 0.14.1 - nestjs-base-service: - specifier: 0.11.1 - version: 0.11.1 - pg: - specifier: 8.12.0 - version: 8.12.0 - reflect-metadata: - specifier: ^0.2.0 - version: 0.2.2 - typeorm: - specifier: 0.3.20 - version: 0.3.20 typescript: specifier: 5.4.5 version: 5.4.5 @@ -399,6 +378,9 @@ importers: framer-motion: specifier: 11.11.9 version: 11.11.9(@emotion/is-prop-valid@1.3.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + jose: + specifier: ^5.9.6 + version: 5.9.6 jotai: specifier: 2.10.1 version: 2.10.1(@types/react@18.3.5)(react@18.3.1) @@ -414,9 +396,6 @@ importers: next: specifier: 14.2.10 version: 14.2.10(@playwright/test@1.44.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - next-auth: - specifier: 4.24.8 - version: 4.24.8(next@14.2.10(@playwright/test@1.44.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@6.9.15)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) nuqs: specifier: 2.0.4 version: 2.0.4(next@14.2.10(@playwright/test@1.44.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-router-dom@6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) @@ -2106,9 +2085,6 @@ packages: engines: {node: '>=8.0.0', npm: '>=5.0.0'} hasBin: true - '@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'} @@ -5806,8 +5782,8 @@ packages: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true - jose@4.15.9: - resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + jose@5.9.6: + resolution: {integrity: sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==} jotai@2.10.1: resolution: {integrity: sha512-4FycO+BOTl2auLyF2Chvi6KTDqdsdDDtpaL/WHQMs8f3KS1E3loiUShQzAzFA/sMU5cJ0hz/RT1xum9YbG/zaA==} @@ -6024,10 +6000,6 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lru-cache@6.0.0: - resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} - engines: {node: '>=10'} - lucide-react@0.447.0: resolution: {integrity: sha512-SZ//hQmvi+kDKrNepArVkYK7/jfeZ5uFNEnYmd45RKZcbGD78KLnrcNXmgeg6m+xNHFvTG+CblszXCy4n6DN4w==} peerDependencies: @@ -6224,20 +6196,6 @@ packages: '@nestjs/common': ^6.1.1 || ^5.6.2 || ^7.0.0 || ^8.0.0 || ^9.0.0 typeorm: ^0.3.0 - next-auth@4.24.8: - resolution: {integrity: sha512-SLt3+8UCtklsotnz2p+nB4aN3IHNmpsQFAZ24VLxGotWGzSxkBh192zxNhm/J5wgkcrDWVp0bwqvW0HksK/Lcw==} - peerDependencies: - '@auth/core': 0.34.2 - next: ^12.2.5 || ^13 || ^14 - nodemailer: ^6.6.5 - react: ^17.0.2 || ^18 - react-dom: ^17.0.2 || ^18 - peerDependenciesMeta: - '@auth/core': - optional: true - nodemailer: - optional: true - next@14.2.10: resolution: {integrity: sha512-sDDExXnh33cY3RkS9JuFEKaS4HmlWmDKP1VJioucCG6z5KuA008DPsDZOzi8UfqEk3Ii+2NCQSJrfbEWtZZfww==} engines: {node: '>=18.17.0'} @@ -6321,17 +6279,10 @@ packages: react-router-dom: optional: true - oauth@0.9.15: - resolution: {integrity: sha512-a5ERWK1kh38ExDEfoO6qUHJb32rd7aYmPHuyCu3Fta/cnICvYmgd2uhuKXvPD+PXB+gCEYYEaQdIRAjCOwAKNA==} - object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} - object-hash@2.2.0: - resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} - engines: {node: '>= 6'} - object-hash@3.0.0: resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} engines: {node: '>= 6'} @@ -6371,10 +6322,6 @@ packages: obuf@1.1.2: resolution: {integrity: sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==} - oidc-token-hash@5.0.3: - resolution: {integrity: sha512-IF4PcGgzAr6XXSff26Sk/+P4KZFJVuHAJZj3wgO3vX2bMdNVp/QXTP3P7CEm9V1IdG8lDLY3HhiqpsE/nOwpPw==} - engines: {node: ^10.13.0 || >=12.0.0} - on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -6390,9 +6337,6 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} - openid-client@5.7.0: - resolution: {integrity: sha512-4GCCGZt1i2kTHpwvaC/sCpTpQqDnBzDzuJcJMbH+y1Q5qI8U8RBvoSh28svarXszZHR5BAMXbJPX1PGPRE3VOA==} - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -6703,14 +6647,6 @@ packages: potpack@2.0.0: resolution: {integrity: sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw==} - preact-render-to-string@5.2.6: - resolution: {integrity: sha512-JyhErpYOvBV1hEPwIxc/fHWXPfnEGdRKxc8gFdAZ7XV4tlzyzG847XAyEZqoDnynP88akM4eaHcSOzNcLWFguw==} - peerDependencies: - preact: '>=10' - - preact@10.24.2: - resolution: {integrity: sha512-1cSoF0aCC8uaARATfrlz4VCBqE8LwZwRfLgkxJOQwAlQt6ayTmi0D9OF7nXid1POI5SZidFuG9CnlXbDfLqY/Q==} - prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -6783,9 +6719,6 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - pretty-format@3.8.0: - resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} - process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -8025,10 +7958,6 @@ packages: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} hasBin: true - uuid@8.3.2: - resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} - hasBin: true - uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true @@ -9781,7 +9710,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 - debug: 4.3.6(supports-color@5.5.0) + debug: 4.3.6 espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -9834,7 +9763,7 @@ snapshots: '@humanwhocodes/config-array@0.11.14': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.3.6(supports-color@5.5.0) + debug: 4.3.6 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -10315,8 +10244,6 @@ snapshots: transitivePeerDependencies: - encoding - '@panva/hkdf@1.2.1': {} - '@pkgjs/parseargs@0.11.0': optional: true @@ -11992,7 +11919,7 @@ snapshots: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.4.5) '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.6(supports-color@5.5.0) + debug: 4.3.6 eslint: 8.57.0 optionalDependencies: typescript: 5.4.5 @@ -12008,7 +11935,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.4.5) '@typescript-eslint/utils': 7.18.0(eslint@8.57.0)(typescript@5.4.5) - debug: 4.3.6(supports-color@5.5.0) + debug: 4.3.6 eslint: 8.57.0 ts-api-utils: 1.3.0(typescript@5.4.5) optionalDependencies: @@ -12022,7 +11949,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.3.6(supports-color@5.5.0) + debug: 4.3.6 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 @@ -13159,6 +13086,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.3.6: + dependencies: + ms: 2.1.2 + debug@4.3.6(supports-color@5.5.0): dependencies: ms: 2.1.2 @@ -13481,10 +13412,10 @@ snapshots: eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.3.6(supports-color@5.5.0) + debug: 4.3.6 enhanced-resolve: 5.17.1 eslint: 8.57.0 - eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.8.0 is-bun-module: 1.1.0 @@ -13497,7 +13428,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.9.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0): + eslint-module-utils@2.9.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -13519,7 +13450,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-plugin-import@2.30.0)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.9.0(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.4.5))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.15.1 is-glob: 4.0.3 @@ -13616,7 +13547,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 - debug: 4.3.6(supports-color@5.5.0) + debug: 4.3.6 doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -14897,7 +14828,7 @@ snapshots: jiti@1.21.6: {} - jose@4.15.9: {} + jose@5.9.6: {} jotai@2.10.1(@types/react@18.3.5)(react@18.3.1): optionalDependencies: @@ -15085,10 +15016,6 @@ snapshots: dependencies: yallist: 3.1.1 - lru-cache@6.0.0: - dependencies: - yallist: 4.0.0 - lucide-react@0.447.0(react@18.3.1): dependencies: react: 18.3.1 @@ -15285,23 +15212,6 @@ snapshots: '@nestjs/common': 10.4.1(class-transformer@0.5.1)(class-validator@0.14.1)(reflect-metadata@0.2.2)(rxjs@7.8.1) typeorm: 0.3.20(pg@8.12.0)(ts-node@10.9.2(@types/node@20.14.2)(typescript@5.4.5)) - next-auth@4.24.8(next@14.2.10(@playwright/test@1.44.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(nodemailer@6.9.15)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@babel/runtime': 7.25.7 - '@panva/hkdf': 1.2.1 - cookie: 0.5.0 - jose: 4.15.9 - next: 14.2.10(@playwright/test@1.44.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - oauth: 0.9.15 - openid-client: 5.7.0 - preact: 10.24.2 - preact-render-to-string: 5.2.6(preact@10.24.2) - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - uuid: 8.3.2 - optionalDependencies: - nodemailer: 6.9.15 - next@14.2.10(@playwright/test@1.44.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@next/env': 14.2.10 @@ -15384,12 +15294,8 @@ snapshots: next: 14.2.10(@playwright/test@1.44.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-router-dom: 6.26.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - oauth@0.9.15: {} - object-assign@4.1.1: {} - object-hash@2.2.0: {} - object-hash@3.0.0: {} object-inspect@1.13.2: {} @@ -15435,8 +15341,6 @@ snapshots: obuf@1.1.2: {} - oidc-token-hash@5.0.3: {} - on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -15451,13 +15355,6 @@ snapshots: dependencies: mimic-fn: 2.1.0 - openid-client@5.7.0: - dependencies: - jose: 4.15.9 - lru-cache: 6.0.0 - object-hash: 2.2.0 - oidc-token-hash: 5.0.3 - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -15744,13 +15641,6 @@ snapshots: potpack@2.0.0: {} - preact-render-to-string@5.2.6(preact@10.24.2): - dependencies: - preact: 10.24.2 - pretty-format: 3.8.0 - - preact@10.24.2: {} - prelude-ls@1.2.1: {} prettier-linter-helpers@1.0.0: @@ -15769,8 +15659,6 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 - pretty-format@3.8.0: {} - process-nextick-args@2.0.1: {} prompts@2.4.2: @@ -17171,8 +17059,6 @@ snapshots: uuid@10.0.0: {} - uuid@8.3.2: {} - uuid@9.0.1: {} v8-compile-cache-lib@3.0.1: {}