From e9a5dd44bbea1f3b04aa375d351a783eebaadbaa Mon Sep 17 00:00:00 2001 From: 0-don Date: Thu, 4 Jul 2024 23:58:53 +0200 Subject: [PATCH] easy --- src/app/(auth)/layout.tsx | 11 +++++++ src/app/(main)/layout.tsx | 16 ++++++++-- src/app/api/[[...route]]/route.ts | 11 +++++++ src/components/hooks/auth-hook.ts | 20 ++++++++++-- src/components/hooks/user-hook.ts | 10 ++++++ src/components/providers/query-provider.tsx | 10 ++++++ src/lib/jwt.ts | 16 ++++++++++ src/lib/react-query.ts | 13 ++++++-- src/lib/rpc.ts | 10 ++++++ src/middleware.ts | 6 ++++ src/server/auth/index.ts | 23 ++++++++++---- src/server/auth/typebox.ts | 6 ++++ src/server/user/index.ts | 6 ++++ src/utils/base.ts | 19 ++++++++++- src/utils/env/client/index.ts | 4 ++- src/utils/env/server/index.ts | 35 +++++++-------------- src/utils/server.ts | 8 +++++ 17 files changed, 186 insertions(+), 38 deletions(-) diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index 5b8430e..e615113 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -8,13 +8,24 @@ interface AuthLayoutProps { children: React.ReactNode; } +/** + * AuthLayout component + * Handles layout for authentication-related pages (e.g., login, register) + * + * @param {AuthLayoutProps} props - Component props + * @param {React.ReactNode} props.children - Child components to be rendered within the layout + */ export default async function AuthLayout(props: AuthLayoutProps) { + // Get the QueryClient instance const queryClient = getQueryClient(); + // Attempt to fetch current user data const { data: me, error: meError } = await rpc.api.user.me.get(setCookies()); + // If user is authenticated and not on the logout page, redirect to dashboard if (!meError && !serverUrl()?.includes("logout")) redirect("/dashboard"); + // Render the layout with hydrated query client state return (
diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index caae4eb..715547a 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -8,15 +8,27 @@ interface MainLayoutProps { children: React.ReactNode; } +/** + * MainLayout component + * Handles user authentication and data prefetching + * + * @param {MainLayoutProps} props - Component props + * @param {React.ReactNode} props.children - Child components to be rendered within the layout + */ export default async function MainLayout(props: MainLayoutProps) { + // Get the QueryClient instance const queryClient = getQueryClient(); - const { data: me, error: meError } = await rpc.api.user.me.get(setCookies()); // need to pass in cookies because its a server component + // Fetch current user data + const { data: me, error: meError } = await rpc.api.user.me.get(setCookies()); + // If there's an error fetching user data, redirect to login page if (meError) redirect("/login"); - queryClient.setQueryData(["me"], me); // set the me data in the query client to be preloaded in "use client" + // Set the fetched user data in the query client cache + queryClient.setQueryData(["me"], me); + // Render the layout with hydrated query client state return ( {props.children} diff --git a/src/app/api/[[...route]]/route.ts b/src/app/api/[[...route]]/route.ts index 4d656d7..d0d3b27 100644 --- a/src/app/api/[[...route]]/route.ts +++ b/src/app/api/[[...route]]/route.ts @@ -2,12 +2,23 @@ import { authRoute } from "@/server/auth"; import { userRoute } from "@/server/user"; import { Elysia } from "elysia"; +/** + * Main API router + * Combines auth and user routes under the '/api' prefix + */ const app = new Elysia({ prefix: "/api", aot: false }) .use(userRoute) .use(authRoute); +/** + * Export the app type for use with RPC clients (e.g., edenTreaty) + */ export type App = typeof app; +/** + * Export handlers for different HTTP methods + * These are used by Next.js API routes + */ export const GET = app.handle; export const POST = app.handle; export const PUT = app.handle; diff --git a/src/components/hooks/auth-hook.ts b/src/components/hooks/auth-hook.ts index 5fbdc6a..d0057e7 100644 --- a/src/components/hooks/auth-hook.ts +++ b/src/components/hooks/auth-hook.ts @@ -2,27 +2,43 @@ import { rpc } from "@/lib/rpc"; import { handleEden } from "@/utils/base"; import { useMutation } from "@tanstack/react-query"; +/** + * Custom hook for authentication operations. + * Provides mutations for register, login, and logout actions. + */ export const AuthHook = () => { - const registerMuation = useMutation({ + /** + * Mutation for user registration. + * Uses the RPC client to call the register endpoint. + */ + const registerMutation = useMutation({ mutationKey: ["register"], mutationFn: async ( ...args: Parameters ) => handleEden(await rpc.api.auth.register.post(...args)), }); + /** + * Mutation for user login. + * Uses the RPC client to call the login endpoint. + */ const loginMutation = useMutation({ mutationKey: ["login"], mutationFn: async (...args: Parameters) => handleEden(await rpc.api.auth.login.post(...args)), }); + /** + * Mutation for user logout. + * Uses the RPC client to call the logout endpoint. + */ const logoutMutation = useMutation({ mutationKey: ["logout"], mutationFn: async () => handleEden(await rpc.api.auth.logout.get()), }); return { - registerMuation, + registerMutation, loginMutation, logoutMutation, }; diff --git a/src/components/hooks/user-hook.ts b/src/components/hooks/user-hook.ts index 406e547..bde13f9 100644 --- a/src/components/hooks/user-hook.ts +++ b/src/components/hooks/user-hook.ts @@ -2,7 +2,17 @@ import { rpc } from "@/lib/rpc"; import { handleEden } from "@/utils/base"; import { useQuery } from "@tanstack/react-query"; +/** + * Custom hook for user-related operations. + * Currently provides a query for fetching the current user's information. + */ export const UserHook = () => { + /** + * Query for fetching the current user's information. + * Uses the RPC client to call the user 'me' endpoint. + * + * Note: This query is disabled by default and needs to be manually enabled. + */ const meQuery = useQuery({ queryKey: ["me"], enabled: false, diff --git a/src/components/providers/query-provider.tsx b/src/components/providers/query-provider.tsx index 3135eec..a653511 100644 --- a/src/components/providers/query-provider.tsx +++ b/src/components/providers/query-provider.tsx @@ -3,11 +3,20 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import React from "react"; +/** + * QueryProvider component + * Sets up a QueryClientProvider for React Query to manage and cache API requests. + * + * @param {Object} props - Component props + * @param {React.ReactNode} props.children - Child components to be wrapped by the provider + */ export default function QueryProvider({ children, }: { children: React.ReactNode; }) { + // Create a new QueryClient instance and store it in state + // This ensures the same instance is used across re-renders const [queryClient] = React.useState( () => new QueryClient({ @@ -15,6 +24,7 @@ export default function QueryProvider({ }), ); + // Wrap children with QueryClientProvider return ( {children} ); diff --git a/src/lib/jwt.ts b/src/lib/jwt.ts index 6443dc6..e518d75 100644 --- a/src/lib/jwt.ts +++ b/src/lib/jwt.ts @@ -1,12 +1,20 @@ import { serverEnv } from "@/utils/env/server"; import * as jose from "jose"; +// Create an encryption key from the server's SECRET environment variable const key = new TextEncoder().encode(serverEnv.SECRET); +/** + * Encrypts a value using JSON Web Encryption (JWE) + * @param value - The value to encrypt (can be any type) + * @returns A Promise that resolves to the encrypted string, or null if encryption fails + */ export const encrypt = async (value: any): Promise => { try { + // Convert objects to JSON strings, leave other types as-is const text = typeof value === "object" ? JSON.stringify(value) : value; + // Create a JWE using direct encryption with AES-GCM const jwe = await new jose.CompactEncrypt(new TextEncoder().encode(text)) .setProtectedHeader({ alg: "dir", enc: "A256GCM" }) .encrypt(key); @@ -17,17 +25,25 @@ export const encrypt = async (value: any): Promise => { } }; +/** + * Decrypts a JWE string + * @param encryptedText - The JWE string to decrypt + * @returns A Promise that resolves to the decrypted value (as type T), or null if decryption fails + */ export const decrypt = async ( encryptedText: string, ): Promise => { if (typeof encryptedText !== "string") return null; try { + // Decrypt the JWE string const { plaintext } = await jose.compactDecrypt(encryptedText, key); const decrypted = new TextDecoder().decode(plaintext); + // Attempt to parse the decrypted text as JSON try { return JSON.parse(decrypted) as T; } catch (error) { + // If parsing fails, return the decrypted text as-is return decrypted as T; } } catch (error) { diff --git a/src/lib/react-query.ts b/src/lib/react-query.ts index 88ee173..f165311 100644 --- a/src/lib/react-query.ts +++ b/src/lib/react-query.ts @@ -1,6 +1,15 @@ import { QueryClient } from "@tanstack/react-query"; import { cache } from "react"; -// global query client for server components only, to not make any duplicate requests +/** + * Creates and caches a global QueryClient instance for server components. + * + * This function uses React's `cache` to ensure that only one QueryClient + * instance is created and reused across all server components. This helps + * prevent duplicate requests and maintains consistent state. + * + * @returns A cached instance of QueryClient + */ const getQueryClient = cache(() => new QueryClient()); -export default getQueryClient; + +export default getQueryClient; \ No newline at end of file diff --git a/src/lib/rpc.ts b/src/lib/rpc.ts index 4ae8801..06c48a5 100644 --- a/src/lib/rpc.ts +++ b/src/lib/rpc.ts @@ -1,6 +1,16 @@ import { App } from "@/app/api/[[...route]]/route"; import { edenTreaty } from "@elysiajs/eden/treaty"; +/** + * Creates an RPC client using edenTreaty. + * + * This setup allows for type-safe API calls between the client and server, + * leveraging the App type from the API route definition. + * + * The base URL for the RPC client is determined dynamically: + * - On the server side, it uses localhost with the specified PORT (or 3000 as default) + * - On the client side, it uses the current window's origin + */ export const rpc = edenTreaty( typeof window === "undefined" ? `http://localhost:${process.env.PORT || 3000}` diff --git a/src/middleware.ts b/src/middleware.ts index 4ed5826..e5b43f1 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,12 +1,18 @@ import { NextResponse, type NextRequest } from "next/server"; import { serverEnv } from "./utils/env/server"; +/** + * Middleware function to set the server URL in the request headers so it can be read while in server components. + */ export default async function middleware(request: NextRequest) { const headers = new Headers(request.headers); headers.set(serverEnv.SERVER_URL_KEY, request.url); return NextResponse.next({ headers }); } +/** + * Configuration for the middleware, specifying which routes it should run on. + */ export const config = { matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], }; diff --git a/src/server/auth/index.ts b/src/server/auth/index.ts index 59a48c5..40ccb3b 100644 --- a/src/server/auth/index.ts +++ b/src/server/auth/index.ts @@ -4,22 +4,30 @@ import { serverEnv } from "@/utils/env/server"; import { Elysia, ParseError } from "elysia"; import { authUser } from "./typebox"; +/** + * Authentication routes for user registration, login, and logout. + * Needs to be combined at the `app/api/[[...route]]/route.ts` file with + * Represents RPC client types based on input & output + */ export const authRoute = new Elysia({ prefix: "/auth" }) .post( "/register", async (ctx) => { + // Check if user already exists const userExist = await prisma.user.findFirst({ where: { username: ctx.body.username.trim() }, }); if (userExist) throw new ParseError("User already exists"); + // Create new user const user = await prisma.user.create({ data: { - username: ctx.body.username.trim(), - password: ctx.body.password.trim(), + username: ctx.body.username.trim(), // typesafe ctx.body based on `authUser` schema + password: ctx.body.password.trim(), // typesafe ctx.body based on `authUser` schema }, }); + // Set authentication cookie ctx.cookie[serverEnv.AUTH_COOKIE].set({ value: await encrypt(user), path: "/", @@ -29,20 +37,22 @@ export const authRoute = new Elysia({ prefix: "/auth" }) return "success"; }, - { body: authUser }, + { body: authUser }, // Use authUser schema for request body validation ) .post( "/login", async (ctx) => { + // Find user by username and password const user = await prisma.user.findFirst({ where: { - username: ctx.body.username.trim(), - password: ctx.body.password.trim(), + username: ctx.body.username.trim(), // typesafe ctx.body based on `authUser` schema + password: ctx.body.password.trim(), // typesafe ctx.body based on `authUser` schema }, }); if (!user) throw new ParseError("User not found"); + // Set authentication cookie ctx.cookie[serverEnv.AUTH_COOKIE].set({ value: await encrypt(user), path: "/", @@ -52,9 +62,10 @@ export const authRoute = new Elysia({ prefix: "/auth" }) return "success"; }, - { body: authUser }, + { body: authUser }, // Use authUser schema for request body validation ) .get("/logout", (ctx) => { + // Clear authentication cookie ctx.cookie[serverEnv.AUTH_COOKIE].set({ value: "", path: "/", diff --git a/src/server/auth/typebox.ts b/src/server/auth/typebox.ts index 089558e..e9da185 100644 --- a/src/server/auth/typebox.ts +++ b/src/server/auth/typebox.ts @@ -1,9 +1,15 @@ import { Type as t, type Static } from "@sinclair/typebox/type"; +/** + * TypeBox schema Can be used for elysia body params or query schema and in frontend form validation. + */ export const authUser = t.Object({ username: t.String({ minLength: 1, maxLength: 128 }), password: t.String({ minLength: 1, maxLength: 128 }), test: t.Optional(t.String({ minLength: 1, maxLength: 128 })), }); +/** + * TypeScript type derived from the authUser schema. + */ export type AuthUser = Static; diff --git a/src/server/user/index.ts b/src/server/user/index.ts index 8840532..df15db2 100644 --- a/src/server/user/index.ts +++ b/src/server/user/index.ts @@ -3,9 +3,15 @@ import { serverEnv } from "@/utils/env/server"; import { User } from "@prisma/client"; import { Elysia, ParseError } from "elysia"; +/** + * User route for retrieving the current user's information. + * Needs to be combined at the `app/api/[[...route]]/route.ts` file. + * Represents RPC client types based on input & output. + */ export const userRoute = new Elysia({ prefix: "/user" }).get( "/me", async (ctx) => { + // Decrypt user information from the authentication jwt cookie const user = await decrypt(ctx.cookie[serverEnv.AUTH_COOKIE].value); if (!user) throw new ParseError("User not found"); diff --git a/src/utils/base.ts b/src/utils/base.ts index eeda544..2898392 100644 --- a/src/utils/base.ts +++ b/src/utils/base.ts @@ -2,7 +2,13 @@ import { Errors } from "@sinclair/typebox/errors"; import type { Static, TSchema } from "@sinclair/typebox/type"; import { Check } from "@sinclair/typebox/value"; - +/** + * Parses a value against a TypeBox schema and throws an error if invalid. + * @param schema The TypeBox schema to validate against. + * @param value The value to validate. + * @returns The validated value. + * @throws Error if the value is invalid according to the schema. + */ export const parse = ( schema: T, value: unknown, @@ -12,6 +18,12 @@ export const parse = ( return value; }; +/** + * Simplifies the response from Elysia tRPC, extracting the data or throwing an error. + * @param response The response object from Elysia tRPC. + * @returns The data from the response. + * @throws EdenFetchError if the response contains an error. + */ export function handleEden( response: ( | { @@ -32,6 +44,11 @@ export function handleEden( return response.data; } +/** + * Represents an error that occurs during an Eden fetch operation. + * @template Status The HTTP status code type. + * @template Value The type of the error value. + */ export declare class EdenFetchError< Status extends number = number, Value = unknown, diff --git a/src/utils/env/client/index.ts b/src/utils/env/client/index.ts index 96f802d..d9eaa86 100644 --- a/src/utils/env/client/index.ts +++ b/src/utils/env/client/index.ts @@ -1,6 +1,7 @@ import { parse } from "@/utils/base"; import { Type as t } from "@sinclair/typebox/type"; +/** Schema for client-side environment variables in typebox */ const clientEnvSchema = t.Object({ URL: t.String({ minLength: 1, @@ -8,6 +9,7 @@ const clientEnvSchema = t.Object({ }), }); +/** Parsed and validated client environment variables */ export const clientEnv = parse(clientEnvSchema, { URL: process.env.NEXT_PUBLIC_URL, -}); +}); \ No newline at end of file diff --git a/src/utils/env/server/index.ts b/src/utils/env/server/index.ts index ab06313..d92053d 100644 --- a/src/utils/env/server/index.ts +++ b/src/utils/env/server/index.ts @@ -1,36 +1,23 @@ import { parse } from "@/utils/base"; import { Type as t } from "@sinclair/typebox/type"; +/** Schema for server-side environment variables in typebox */ const serverEnvSchema = t.Object({ - DATABASE_URL: t.String({ - minLength: 1, - error: "DATABASE_URL server environment variable is not set!", - }), - SECRET: t.String({ - minLength: 1, - error: "SECRET server environment variable is not set!", - }), - NODE_ENV: t.Union([t.Literal("development"), t.Literal("production")], { - error: "NODE_ENV server environment variable is not set!", - }), - AUTH_COOKIE: t.Literal("auth", { - error: "AUTH_COOKIE server environment variable is not set!", - }), - SERVER_URL_KEY: t.Literal("x-url", { - minLength: 1, - error: "SERVER_URL server environment variable is not set!", - }), - SEVEN_DAYS: t.Integer({ - minimum: 1, - error: "SEVEN_DAYS server environment variable is not set!", - }), + DATABASE_URL: t.String({ minLength: 1, error: "DATABASE_URL not set!" }), + SECRET: t.String({ minLength: 1, error: "SECRET not set!" }), + NODE_ENV: t.Union([t.Literal("development"), t.Literal("production")], + { error: "NODE_ENV not set!" }), + AUTH_COOKIE: t.Literal("auth", { error: "AUTH_COOKIE not set!" }), + SERVER_URL_KEY: t.Literal("x-url", { error: "SERVER_URL not set!" }), + SEVEN_DAYS: t.Integer({ minimum: 1, error: "SEVEN_DAYS not set!" }), }); +/** Parsed and validated server environment variables */ export const serverEnv = parse(serverEnvSchema, { DATABASE_URL: process.env.DATABASE_URL, SECRET: process.env.SECRET, NODE_ENV: process.env.NODE_ENV, AUTH_COOKIE: "auth", SERVER_URL_KEY: "x-url", - SEVEN_DAYS: 60 * 60 * 24 * 7, -}); + SEVEN_DAYS: 60 * 60 * 24 * 7, // 7 days in seconds +}); \ No newline at end of file diff --git a/src/utils/server.ts b/src/utils/server.ts index 1c0e62c..c81ec5c 100644 --- a/src/utils/server.ts +++ b/src/utils/server.ts @@ -2,6 +2,10 @@ import { rpc } from "@/lib/rpc"; import { cookies, headers } from "next/headers"; import { serverEnv } from "./env/server"; +/** + * Generates the cookie header for RPC calls in server components ONLY. + * @returns An object containing the cookie header for authentication. + */ export const setCookies = (): Parameters[0] => ({ $headers: { cookie: [serverEnv.AUTH_COOKIE] @@ -10,4 +14,8 @@ export const setCookies = (): Parameters[0] => ({ }, }); +/** + * Retrieves the server URL from server components only, connected with `middlerware.ts` + * @returns The server URL string or undefined if not present. + */ export const serverUrl = () => headers().get(serverEnv.SERVER_URL_KEY);