diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index cc66022..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "next/core-web-vitals", - "overrides": [ - { - "files": ["**/*.tsx"], - "rules": { - "react-hooks/exhaustive-deps": "off" - } - } - ] -} diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 79e1b45..e99a409 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -12,7 +12,7 @@ jobs: runs-on: self-hosted steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Create env file run: | diff --git a/Dockerfile b/Dockerfile index 92926f9..1529025 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ WORKDIR /app COPY package.json ./ COPY prisma ./prisma -RUN npm install +RUN npm install --force # FROM node:20-alpine AS builder diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..4d251e2 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,38 @@ +import js from "@eslint/js"; +import nextPlugin from "@next/eslint-plugin-next"; +import tsParser from "@typescript-eslint/parser"; +import reactHooksPlugin from "eslint-plugin-react-hooks"; +import globals from "globals"; + +export default [ + js.configs.recommended, + { + files: ["**/*.{js,jsx,ts,tsx}"], + languageOptions: { + parser: tsParser, + globals: { + ...globals.browser, + ...globals.es2021, + ...globals.node, + React: "readonly", + }, + }, + plugins: { + "@next/next": nextPlugin, + "react-hooks": reactHooksPlugin, + }, + rules: { + "react-hooks/exhaustive-deps": "off", + "no-unused-vars": [ + "warn", + { + varsIgnorePattern: "^(NodeJS|other)$", + ignoreRestSiblings: true, + args: "none", + caughtErrors: "none", + }, + ], + ...nextPlugin.configs.recommended.rules, + }, + }, +]; diff --git a/next.config.mjs b/next.config.mjs deleted file mode 100644 index 4678774..0000000 --- a/next.config.mjs +++ /dev/null @@ -1,4 +0,0 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = {}; - -export default nextConfig; diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..0ee192b --- /dev/null +++ b/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + /* config options here */ + poweredByHeader: false, +}; + +export default nextConfig; diff --git a/package.json b/package.json index 4ed6728..d2d0edb 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,10 @@ { "name": "next-elysia-prisma", "version": "1.0.0", + "type": "module", "private": true, "scripts": { - "dev": "next dev", + "dev": "next dev --turbopack", "build": "next build", "start": "prisma migrate deploy && next start", "lint": "next lint", @@ -12,26 +13,26 @@ }, "dependencies": { "@elysiajs/eden": "^1.1.3", - "@prisma/client": "^5.20.0", - "@tanstack/react-query": "^5.59.6", - "elysia": "^1.1.19", - "jose": "^5.9.3", - "next": "14.2.15", - "react": "^18", - "react-dom": "^18" + "@prisma/client": "^5.22.0", + "@tanstack/react-query": "^5.61.0", + "elysia": "^1.1.25", + "jose": "^5.9.6", + "next": "15.0.3", + "react": "19.0.0-rc-66855b96-20241106", + "react-dom": "19.0.0-rc-66855b96-20241106" }, "devDependencies": { - "@types/bun": "^1.1.11", - "@types/node": "^22.7.5", + "@types/bun": "^1.1.13", + "@types/node": "^22.9.1", "@types/react": "^18", "@types/react-dom": "^18", - "eslint": "^8", - "eslint-config-next": "14.2.15", + "eslint": "^9.15.0", + "eslint-config-next": "15.0.3", "postcss": "^8", "prettier": "^3.3.3", - "prettier-plugin-tailwindcss": "^0.6.8", - "prisma": "^5.20.0", - "tailwindcss": "^3.4.13", + "prettier-plugin-tailwindcss": "^0.6.9", + "prisma": "^5.22.0", + "tailwindcss": "^3.4.15", "typescript": "^5" } -} \ No newline at end of file +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx index 2f5530d..2c98a37 100644 --- a/src/app/(auth)/layout.tsx +++ b/src/app/(auth)/layout.tsx @@ -13,10 +13,11 @@ export default async function AuthLayout(props: AuthLayoutProps) { const queryClient = getQueryClient(); // Fetch current user data set cookies are required else they will be empty - const { data: me, error: meError } = await rpc.api.user.me.get(setCookies()); + const { error: meError } = await rpc.api.user.me.get(await setCookies()); // serverUrl is a custom function because nextjs doesnt provide a way to read current url in server components - if (!meError && !serverUrl()?.includes("logout")) redirect("/dashboard"); + if (!meError && !(await serverUrl())?.includes("logout")) + redirect("/dashboard"); return ( diff --git a/src/app/(auth)/login/page.tsx b/src/app/(auth)/login/page.tsx index d8f7803..7ab1bc9 100644 --- a/src/app/(auth)/login/page.tsx +++ b/src/app/(auth)/login/page.tsx @@ -1,7 +1,7 @@ "use client"; import { AuthHook } from "@/components/hooks/auth-hook"; -import { authUser } from "@/lib/typebox/auth"; +import { authenticationSchema, authSchemas } from "@/lib/typebox/auth"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { FormEvent, useState } from "react"; @@ -22,7 +22,7 @@ export default function LoginPage(props: LoginPageProps) { username, password, }) - .then((user) => (user ? router.push("/dashboard") : setStatus(user))) + .then(() => router.push("/dashboard")) .catch((error) => setStatus(JSON.stringify(error))); }; @@ -34,9 +34,11 @@ export default function LoginPage(props: LoginPageProps) { id="username" type="text" value={username} - minLength={authUser.properties.username.minLength} + minLength={authenticationSchema.properties.username.minLength} + maxLength={authenticationSchema.properties.username.maxLength} onChange={(e) => setUsername(e.target.value)} required + autoComplete="username" />
@@ -44,13 +46,26 @@ export default function LoginPage(props: LoginPageProps) { setPassword(e.target.value)} required + autoComplete="current-password" />
- diff --git a/src/app/(auth)/register/page.tsx b/src/app/(auth)/register/page.tsx index 1e9d05e..104c3a6 100644 --- a/src/app/(auth)/register/page.tsx +++ b/src/app/(auth)/register/page.tsx @@ -1,13 +1,12 @@ "use client"; import { AuthHook } from "@/components/hooks/auth-hook"; +import { authenticationSchema, authSchemas } from "@/lib/typebox/auth"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { FormEvent, useState } from "react"; -interface RegisterPageProps {} - -export default function RegisterPage(props: RegisterPageProps) { +export default function RegisterPage() { const router = useRouter(); const { registerMutation } = AuthHook(); const [status, setStatus] = useState(""); @@ -21,7 +20,7 @@ export default function RegisterPage(props: RegisterPageProps) { username, password, }) - .then((user) => (user ? router.push("/dashboard") : setStatus(user))) + .then(() => router.push("/dashboard")) .catch((error) => setStatus(JSON.stringify(error))); }; @@ -34,7 +33,10 @@ export default function RegisterPage(props: RegisterPageProps) { type="text" value={username} onChange={(e) => setUsername(e.target.value)} + minLength={authenticationSchema.properties.username.minLength} + maxLength={authenticationSchema.properties.username.maxLength} required + autoComplete="username" />
@@ -43,12 +45,25 @@ export default function RegisterPage(props: RegisterPageProps) { id="password" type="password" value={password} + minLength={authenticationSchema.properties.password.minLength} + maxLength={authenticationSchema.properties.password.maxLength} onChange={(e) => setPassword(e.target.value)} required + autoComplete="current-password" />
- diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index e70dacc..b78a4f7 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -13,7 +13,9 @@ export default async function MainLayout(props: MainLayoutProps) { const queryClient = getQueryClient(); // Fetch current user data set cookies are required else they will be empty - const { data: me, error: meError } = await rpc.api.user.me.get(setCookies()); + const { data: me, error: meError } = await rpc.api.user.me.get( + await setCookies(), + ); if (meError) redirect("/login"); diff --git a/src/app/api/[[...route]]/route.ts b/src/app/api/[[...route]]/route.ts index 7a7101a..770b1e0 100644 --- a/src/app/api/[[...route]]/route.ts +++ b/src/app/api/[[...route]]/route.ts @@ -2,9 +2,6 @@ import { authRoute } from "@/server/auth"; import { userRoute } from "@/server/user"; import { Elysia } from "elysia"; -/** - * Force dynamic import for RPC clients - */ export const dynamic = "force-dynamic"; /** diff --git a/src/lib/typebox/auth.ts b/src/lib/typebox/auth.ts index e9da185..2d5b61d 100644 --- a/src/lib/typebox/auth.ts +++ b/src/lib/typebox/auth.ts @@ -1,15 +1,20 @@ import { Type as t, type Static } from "@sinclair/typebox/type"; +import Elysia from "elysia"; /** * TypeBox schema Can be used for elysia body params or query schema and in frontend form validation. */ -export const authUser = t.Object({ +export const authenticationSchema = 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 })), }); +export const { models: authSchemas } = new Elysia().model({ + authUser: authenticationSchema, +}); + /** * TypeScript type derived from the authUser schema. */ -export type AuthUser = Static; +export type AuthUser = Static; diff --git a/src/middleware.ts b/src/middleware.ts index 44a379e..4f7e7bd 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -11,5 +11,6 @@ export default async function middleware(request: NextRequest) { } export const config = { - matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"], + matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"], + unstable_allowDynamic: ["/node_modules/elysia/**"], }; diff --git a/src/server/auth/index.ts b/src/server/auth/index.ts index de913b2..6177c49 100644 --- a/src/server/auth/index.ts +++ b/src/server/auth/index.ts @@ -1,8 +1,9 @@ import { encrypt } from "@/lib/jwt"; import prisma from "@/lib/prisma"; -import { authUser } from "@/lib/typebox/auth"; +import { authenticationSchema } from "@/lib/typebox/auth"; import { serverEnv } from "@/utils/env/server"; import { Elysia, InternalServerError } from "elysia"; +import { ResponseCookie } from "next/dist/compiled/@edge-runtime/cookies"; import { cookies } from "next/headers"; /** @@ -28,18 +29,17 @@ export const authRoute = new Elysia({ prefix: "/auth" }) }, }); - // Set authentication cookie - cookies().set({ + const cookie: ResponseCookie = { name: serverEnv.AUTH_COOKIE, value: (await encrypt(user))!, path: "/", httpOnly: true, maxAge: serverEnv.SEVEN_DAYS, - }); - - return "success"; + }; + // Set authentication cookie + (await cookies()).set(cookie); }, - { body: authUser }, // Use authUser schema for request body validation + { body: authenticationSchema }, // Use authUser schema for request body validation ) .post( "/login", @@ -54,19 +54,19 @@ export const authRoute = new Elysia({ prefix: "/auth" }) if (!user) throw new InternalServerError("User not found"); - cookies().set({ + const cookie: ResponseCookie = { name: serverEnv.AUTH_COOKIE, value: (await encrypt(user))!, path: "/", httpOnly: true, maxAge: serverEnv.SEVEN_DAYS, - }); - - return "success"; + }; + // Set authentication cookie + (await cookies()).set(cookie); }, - { body: authUser }, // Use authUser schema for request body validation + { body: authenticationSchema }, // Use authUser schema for request body validation ) - .get("/logout", (ctx) => { + .get("/logout", async (ctx) => { // Clear authentication cookie - return !!cookies().delete(serverEnv.AUTH_COOKIE); + !!(await cookies()).delete(serverEnv.AUTH_COOKIE); }); diff --git a/src/server/user/index.ts b/src/server/user/index.ts index 550f6eb..7bf564d 100644 --- a/src/server/user/index.ts +++ b/src/server/user/index.ts @@ -2,6 +2,7 @@ import { decrypt } from "@/lib/jwt"; import { serverEnv } from "@/utils/env/server"; import { User } from "@prisma/client"; import { Elysia, InternalServerError } from "elysia"; +import { cookies } from "next/headers"; /** * User route for retrieving the current user's information. @@ -11,8 +12,9 @@ import { Elysia, InternalServerError } from "elysia"; 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); + const user = await decrypt( + (await cookies()).get(serverEnv.AUTH_COOKIE)?.value, + ); if (!user) throw new InternalServerError("User not found"); diff --git a/src/utils/env/client/index.ts b/src/utils/env/client/index.ts index d9eaa86..5168faf 100644 --- a/src/utils/env/client/index.ts +++ b/src/utils/env/client/index.ts @@ -1,15 +1,28 @@ -import { parse } from "@/utils/base"; -import { Type as t } from "@sinclair/typebox/type"; +import { Elysia, t } from "elysia"; /** Schema for client-side environment variables in typebox */ -const clientEnvSchema = t.Object({ - URL: t.String({ - minLength: 1, - error: "URL client environment variable is not set!", +const { + models: { clientSchema }, +} = new Elysia().model({ + clientSchema: t.Object({ + URL: t.String({ + minLength: 1, + error: "URL client environment variable is not set!", + }), }), }); -/** Parsed and validated client environment variables */ -export const clientEnv = parse(clientEnvSchema, { +const clientEnvResult = clientSchema.safeParse({ URL: process.env.NEXT_PUBLIC_URL, -}); \ No newline at end of file +}); + +if (!clientEnvResult.data) { + const firstError = clientEnvResult.errors[0]; + if (firstError) + throw new Error( + `Invalid client environment variable ${firstError.path.slice(1)}: ${firstError.summary.replaceAll(" ", " ")}`, + ); + else throw new Error(`Invalid client environment ${clientEnvResult.error}`); +} + +export const clientEnv = clientEnvResult.data; diff --git a/src/utils/env/server/index.ts b/src/utils/env/server/index.ts index d92053d..0d1e4e2 100644 --- a/src/utils/env/server/index.ts +++ b/src/utils/env/server/index.ts @@ -1,23 +1,39 @@ -import { parse } from "@/utils/base"; -import { Type as t } from "@sinclair/typebox/type"; +import { Elysia, t } from "elysia"; -/** Schema for server-side environment variables in typebox */ -const serverEnvSchema = t.Object({ - 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!" }), +const { + models: { serverSchema }, +} = new Elysia().model({ + serverSchema: t.Object({ + 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("test"), 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, { +const serverEnvResult = serverSchema.safeParse({ 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, // 7 days in seconds -}); \ No newline at end of file +}); + +if (!serverEnvResult.data) { + const firstError = serverEnvResult.errors[0]; + if (firstError) + throw new Error( + `Invalid server environment variable ${firstError.path.slice(1)}: ${firstError.summary.replaceAll(" ", " ")}`, + ); + else throw new Error(`Invalid server environment ${serverEnvResult.error}`); +} + +export const serverEnv = serverEnvResult.data; diff --git a/src/utils/server.ts b/src/utils/server.ts index 70c3aa6..3d7684e 100644 --- a/src/utils/server.ts +++ b/src/utils/server.ts @@ -5,16 +5,22 @@ import { serverEnv } from "./env/server"; * get cookie from nextjs header for RPC calls in server components ONLY. * @returns An object containing the cookie header for authentication. */ -export const setCookies = () => ({ - $headers: { - cookie: [serverEnv.AUTH_COOKIE] - .map((cookie) => `${cookie}=${cookies().get(cookie)?.value}`) - .join("; "), - }, -}); +export const setCookies = async () => { + const cookieStore = await cookies(); + const cookie = [serverEnv.AUTH_COOKIE] + .map((name) => { + const value = cookieStore.get(name)?.value; + return value ? `${name}=${value}` : ""; + }) + .filter(Boolean) + .join("; "); + + return { $headers: { cookie } }; +}; /** * 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); +export const serverUrl = async () => + (await headers()).get(serverEnv.SERVER_URL_KEY); diff --git a/tsconfig.json b/tsconfig.json index 9e8c5da..d3bd61c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "target": "ES2017", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true,