Skip to content

Commit

Permalink
easy
Browse files Browse the repository at this point in the history
  • Loading branch information
0-don committed Jul 4, 2024
1 parent 9a926e6 commit e9a5dd4
Show file tree
Hide file tree
Showing 17 changed files with 186 additions and 38 deletions.
11 changes: 11 additions & 0 deletions src/app/(auth)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<HydrationBoundary state={dehydrate(queryClient)}>
<main className="flex h-screen">
Expand Down
16 changes: 14 additions & 2 deletions src/app/(main)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<HydrationBoundary state={dehydrate(queryClient)}>
{props.children}
Expand Down
11 changes: 11 additions & 0 deletions src/app/api/[[...route]]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
20 changes: 18 additions & 2 deletions src/components/hooks/auth-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof rpc.api.auth.register.post>
) => 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<typeof rpc.api.auth.login.post>) =>
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,
};
Expand Down
10 changes: 10 additions & 0 deletions src/components/hooks/user-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions src/components/providers/query-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,28 @@
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({
defaultOptions: { queries: { refetchOnWindowFocus: false } },
}),
);

// Wrap children with QueryClientProvider
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
Expand Down
16 changes: 16 additions & 0 deletions src/lib/jwt.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> => {
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);
Expand All @@ -17,17 +25,25 @@ export const encrypt = async (value: any): Promise<string | null> => {
}
};

/**
* 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 <T = string>(
encryptedText: string,
): Promise<T | null> => {
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) {
Expand Down
13 changes: 11 additions & 2 deletions src/lib/react-query.ts
Original file line number Diff line number Diff line change
@@ -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;
10 changes: 10 additions & 0 deletions src/lib/rpc.ts
Original file line number Diff line number Diff line change
@@ -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<App>(
typeof window === "undefined"
? `http://localhost:${process.env.PORT || 3000}`
Expand Down
6 changes: 6 additions & 0 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -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).*)"],
};
23 changes: 17 additions & 6 deletions src/server/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: "/",
Expand All @@ -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: "/",
Expand All @@ -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: "/",
Expand Down
6 changes: 6 additions & 0 deletions src/server/auth/typebox.ts
Original file line number Diff line number Diff line change
@@ -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<typeof authUser>;
6 changes: 6 additions & 0 deletions src/server/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<User>(ctx.cookie[serverEnv.AUTH_COOKIE].value);

if (!user) throw new ParseError("User not found");
Expand Down
Loading

0 comments on commit e9a5dd4

Please sign in to comment.