better-ts-stack 1.1.4 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +113 -82
- package/dist/args.js +90 -0
- package/dist/builder/buildPlanner.js +71 -0
- package/dist/builder/configGenerator.js +19 -10
- package/dist/builder/dependencyInstaller.js +8 -15
- package/dist/builder/fileProcessor.js +47 -44
- package/dist/builder/gitInitializer.js +21 -35
- package/dist/builder/index.js +33 -91
- package/dist/builder/moduleSelector.js +11 -16
- package/dist/builder/templateContext.js +6 -27
- package/dist/index.js +47 -21
- package/dist/modules/registry.js +4 -6
- package/dist/output/nextSteps.js +10 -20
- package/dist/prompts/index.js +22 -59
- package/dist/prompts/stack.js +85 -71
- package/dist/types/index.js +47 -41
- package/dist/ui/index.js +18 -0
- package/dist/ui/messages.js +124 -0
- package/dist/ui/prompts.js +70 -0
- package/dist/ui/theme.js +23 -0
- package/dist/validators/index.js +15 -40
- package/package.json +71 -66
- package/templates/backend/express/.prettierrc +2 -2
- package/templates/backend/express/config.json +40 -44
- package/templates/backend/express/src/{index.ts → index.ts.hbs} +75 -76
- package/templates/backend/nestjs/.prettierignore +52 -0
- package/templates/backend/nestjs/.prettierrc +16 -0
- package/templates/backend/nestjs/config.json +41 -0
- package/templates/backend/nestjs/eslint.config.mjs +40 -0
- package/templates/backend/nestjs/gitignore +36 -0
- package/templates/backend/nestjs/nest-cli.json +9 -0
- package/templates/backend/nestjs/src/app.module.ts.hbs +34 -0
- package/templates/backend/nestjs/src/health/health.controller.ts +12 -0
- package/templates/backend/nestjs/src/main.ts +23 -0
- package/templates/backend/nestjs/tsconfig.eslint.json +9 -0
- package/templates/backend/nestjs/tsconfig.json +25 -0
- package/templates/frontend/nextjs/app/layout.tsx +13 -13
- package/templates/frontend/nextjs/app/page.tsx.hbs +97 -97
- package/templates/frontend/nextjs/components/ui/button.tsx +51 -51
- package/templates/frontend/nextjs/components/ui/card.tsx +60 -60
- package/templates/frontend/nextjs/components/ui/field.tsx +67 -67
- package/templates/frontend/nextjs/components/ui/input.tsx +18 -18
- package/templates/frontend/nextjs/config.json +37 -38
- package/templates/frontend/nextjs/eslint.config.mjs +11 -11
- package/templates/frontend/nextjs/next.config.ts.hbs +12 -12
- package/templates/frontend/tanstack/config.json +45 -48
- package/templates/modules/auth/express/config.json +20 -26
- package/templates/modules/auth/express/src/controllers/authController.ts.hbs +43 -57
- package/templates/modules/auth/nestjs/config.json +20 -0
- package/templates/modules/auth/nestjs/src/auth/auth.controller.ts +46 -0
- package/templates/modules/auth/nestjs/src/auth/auth.guard.ts +36 -0
- package/templates/modules/auth/nestjs/src/auth/auth.module.ts.hbs +22 -0
- package/templates/modules/auth/nestjs/src/auth/auth.schemas.ts +35 -0
- package/templates/modules/auth/nestjs/src/auth/auth.service.ts +70 -0
- package/templates/modules/auth/nestjs/src/auth/jwt.ts +27 -0
- package/templates/modules/auth/nestjs/src/auth/users.service.ts.hbs +144 -0
- package/templates/modules/auth/nestjs/src/auth/zod-validation.pipe.ts +16 -0
- package/templates/modules/auth/nextjs/config.json +18 -31
- package/templates/modules/auth/tanstack/config.json +2 -16
- package/templates/modules/docker/Dockerfile.hbs +135 -133
- package/templates/modules/docker/config.json +15 -19
- package/templates/modules/drizzle/express/config.json +26 -27
- package/templates/modules/drizzle/nestjs/config.json +26 -0
- package/templates/modules/drizzle/nestjs/drizzle.config.ts +13 -0
- package/templates/modules/drizzle/nestjs/src/database/database.module.ts +30 -0
- package/templates/modules/drizzle/nestjs/src/database/schema.ts +10 -0
- package/templates/modules/drizzle/nextjs/config.json +26 -29
- package/templates/modules/drizzle/tanstack/config.json +5 -5
- package/templates/modules/mongoose/express/config.json +15 -16
- package/templates/modules/mongoose/nestjs/config.json +15 -0
- package/templates/modules/mongoose/nestjs/src/users/user.schema.ts +21 -0
- package/templates/modules/mongoose/nextjs/config.json +15 -16
- package/templates/modules/mongoose/tanstack/config.json +16 -17
- package/templates/modules/prisma/express/config.json +27 -28
- package/templates/modules/prisma/nestjs/config.json +27 -0
- package/templates/modules/prisma/nestjs/prisma/schema.prisma +23 -0
- package/templates/modules/prisma/nestjs/prisma.config.ts +12 -0
- package/templates/modules/prisma/nestjs/src/prisma/prisma.module.ts +11 -0
- package/templates/modules/prisma/nestjs/src/prisma/prisma.service.ts +17 -0
- package/templates/modules/prisma/nextjs/config.json +28 -29
- package/templates/modules/prisma/tanstack/config.json +5 -6
- package/dist/builder/configGenerator.d.ts +0 -7
- package/dist/builder/configGenerator.d.ts.map +0 -1
- package/dist/builder/configGenerator.js.map +0 -1
- package/dist/builder/dependencyInstaller.d.ts +0 -3
- package/dist/builder/dependencyInstaller.d.ts.map +0 -1
- package/dist/builder/dependencyInstaller.js.map +0 -1
- package/dist/builder/fileProcessor.d.ts +0 -6
- package/dist/builder/fileProcessor.d.ts.map +0 -1
- package/dist/builder/fileProcessor.js.map +0 -1
- package/dist/builder/gitInitializer.d.ts +0 -2
- package/dist/builder/gitInitializer.d.ts.map +0 -1
- package/dist/builder/gitInitializer.js.map +0 -1
- package/dist/builder/index.d.ts +0 -3
- package/dist/builder/index.d.ts.map +0 -1
- package/dist/builder/index.js.map +0 -1
- package/dist/builder/moduleSelector.d.ts +0 -9
- package/dist/builder/moduleSelector.d.ts.map +0 -1
- package/dist/builder/moduleSelector.js.map +0 -1
- package/dist/builder/templateContext.d.ts +0 -21
- package/dist/builder/templateContext.d.ts.map +0 -1
- package/dist/builder/templateContext.js.map +0 -1
- package/dist/index.d.ts +0 -2
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/intro.d.ts +0 -2
- package/dist/intro.d.ts.map +0 -1
- package/dist/intro.js +0 -27
- package/dist/intro.js.map +0 -1
- package/dist/modules/registry.d.ts +0 -6
- package/dist/modules/registry.d.ts.map +0 -1
- package/dist/modules/registry.js.map +0 -1
- package/dist/output/nextSteps.d.ts +0 -4
- package/dist/output/nextSteps.d.ts.map +0 -1
- package/dist/output/nextSteps.js.map +0 -1
- package/dist/prompts/index.d.ts +0 -4
- package/dist/prompts/index.d.ts.map +0 -1
- package/dist/prompts/index.js.map +0 -1
- package/dist/prompts/stack.d.ts +0 -3
- package/dist/prompts/stack.d.ts.map +0 -1
- package/dist/prompts/stack.js.map +0 -1
- package/dist/types/index.d.ts +0 -159
- package/dist/types/index.d.ts.map +0 -1
- package/dist/types/index.js.map +0 -1
- package/dist/validators/index.d.ts +0 -5
- package/dist/validators/index.d.ts.map +0 -1
- package/dist/validators/index.js.map +0 -1
- package/templates/backend/express/.eslintrc.js +0 -24
|
@@ -1,38 +1,37 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "nextjs",
|
|
3
|
-
"name": "Next.js",
|
|
4
|
-
"description": "Next.js 16 App Router with TypeScript and Tailwind CSS",
|
|
5
|
-
"type": "base",
|
|
6
|
-
"scripts": {
|
|
7
|
-
"dev": "next dev",
|
|
8
|
-
"build": "next build",
|
|
9
|
-
"start": "next start",
|
|
10
|
-
"lint": "eslint",
|
|
11
|
-
"type:check": "tsc --noEmit"
|
|
12
|
-
},
|
|
13
|
-
"dependencies": {
|
|
14
|
-
"class-variance-authority": "^0.7.1",
|
|
15
|
-
"clsx": "^2.1.1",
|
|
16
|
-
"lucide-react": "^1.41.0",
|
|
17
|
-
"next": "^16.3.4",
|
|
18
|
-
"react": "^19.2.8",
|
|
19
|
-
"react-dom": "^19.2.8",
|
|
20
|
-
"tailwind-merge": "^3.6.0",
|
|
21
|
-
"@radix-ui/react-slot": "^1.3.3"
|
|
22
|
-
},
|
|
23
|
-
"devDependencies": {
|
|
24
|
-
"@tailwindcss/postcss": "^4.3.3",
|
|
25
|
-
"@types/node": "^24.13.3",
|
|
26
|
-
"@types/react": "^19.2.18",
|
|
27
|
-
"@types/react-dom": "^19.2.7",
|
|
28
|
-
"eslint": "^10.10.0",
|
|
29
|
-
"eslint-config-next": "^16.3.4",
|
|
30
|
-
"tailwindcss": "^4.3.3",
|
|
31
|
-
"typescript": "^6.0.3",
|
|
32
|
-
"@eslint/compat": "^2.1.1"
|
|
33
|
-
},
|
|
34
|
-
"envVars": {
|
|
35
|
-
"PORT": "3000"
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"id": "nextjs",
|
|
3
|
+
"name": "Next.js",
|
|
4
|
+
"description": "Next.js 16 App Router with TypeScript and Tailwind CSS",
|
|
5
|
+
"type": "base",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "next dev",
|
|
8
|
+
"build": "next build",
|
|
9
|
+
"start": "next start",
|
|
10
|
+
"lint": "eslint",
|
|
11
|
+
"type:check": "tsc --noEmit"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"class-variance-authority": "^0.7.1",
|
|
15
|
+
"clsx": "^2.1.1",
|
|
16
|
+
"lucide-react": "^1.41.0",
|
|
17
|
+
"next": "^16.3.4",
|
|
18
|
+
"react": "^19.2.8",
|
|
19
|
+
"react-dom": "^19.2.8",
|
|
20
|
+
"tailwind-merge": "^3.6.0",
|
|
21
|
+
"@radix-ui/react-slot": "^1.3.3"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@tailwindcss/postcss": "^4.3.3",
|
|
25
|
+
"@types/node": "^24.13.3",
|
|
26
|
+
"@types/react": "^19.2.18",
|
|
27
|
+
"@types/react-dom": "^19.2.7",
|
|
28
|
+
"eslint": "^10.10.0",
|
|
29
|
+
"eslint-config-next": "^16.3.4",
|
|
30
|
+
"tailwindcss": "^4.3.3",
|
|
31
|
+
"typescript": "^6.0.3",
|
|
32
|
+
"@eslint/compat": "^2.1.1"
|
|
33
|
+
},
|
|
34
|
+
"envVars": {
|
|
35
|
+
"PORT": "3000"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { fixupConfigRules } from "@eslint/compat";
|
|
2
|
-
import nextVitals from "eslint-config-next/core-web-vitals";
|
|
3
|
-
import nextTs from "eslint-config-next/typescript";
|
|
4
|
-
import { defineConfig, globalIgnores } from "eslint/config";
|
|
5
|
-
|
|
6
|
-
const eslintConfig = defineConfig([
|
|
7
|
-
...fixupConfigRules([...nextVitals, ...nextTs]),
|
|
8
|
-
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
|
|
9
|
-
]);
|
|
10
|
-
|
|
11
|
-
export default eslintConfig;
|
|
1
|
+
import { fixupConfigRules } from "@eslint/compat";
|
|
2
|
+
import nextVitals from "eslint-config-next/core-web-vitals";
|
|
3
|
+
import nextTs from "eslint-config-next/typescript";
|
|
4
|
+
import { defineConfig, globalIgnores } from "eslint/config";
|
|
5
|
+
|
|
6
|
+
const eslintConfig = defineConfig([
|
|
7
|
+
...fixupConfigRules([...nextVitals, ...nextTs]),
|
|
8
|
+
globalIgnores([".next/**", "out/**", "build/**", "next-env.d.ts"]),
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export default eslintConfig;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
import type { NextConfig } from "next";
|
|
2
|
-
|
|
3
|
-
{{#if useDocker}}
|
|
4
|
-
const nextConfig: NextConfig = {
|
|
5
|
-
// Standalone output is what the Docker image runs (node server.js)
|
|
6
|
-
output: "standalone",
|
|
7
|
-
};
|
|
8
|
-
{{else}}
|
|
9
|
-
const nextConfig: NextConfig = {};
|
|
10
|
-
{{/if}}
|
|
11
|
-
|
|
12
|
-
export default nextConfig;
|
|
1
|
+
import type { NextConfig } from "next";
|
|
2
|
+
|
|
3
|
+
{{#if useDocker}}
|
|
4
|
+
const nextConfig: NextConfig = {
|
|
5
|
+
// Standalone output is what the Docker image runs (node server.js)
|
|
6
|
+
output: "standalone",
|
|
7
|
+
};
|
|
8
|
+
{{else}}
|
|
9
|
+
const nextConfig: NextConfig = {};
|
|
10
|
+
{{/if}}
|
|
11
|
+
|
|
12
|
+
export default nextConfig;
|
|
@@ -1,48 +1,45 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "tanstack",
|
|
3
|
-
"name": "TanStack Start",
|
|
4
|
-
"description": "TanStack Start with TanStack Router, React 19, and Tailwind CSS v4",
|
|
5
|
-
"type": "base",
|
|
6
|
-
"scripts": {
|
|
7
|
-
"dev": "vite dev",
|
|
8
|
-
"build": "vite build",
|
|
9
|
-
"start": "srvx --prod -s ../client dist/server/server.js",
|
|
10
|
-
"preview": "vite preview",
|
|
11
|
-
"type:check": "tsc --noEmit",
|
|
12
|
-
"lint": "eslint src"
|
|
13
|
-
},
|
|
14
|
-
"dependencies": {
|
|
15
|
-
"@tanstack/react-query": "^5.102.8",
|
|
16
|
-
"@tanstack/react-query-devtools": "^5.102.8",
|
|
17
|
-
"@tanstack/react-router": "^1.170.36",
|
|
18
|
-
"@tanstack/react-router-devtools": "^1.167.2",
|
|
19
|
-
"@tanstack/react-router-ssr-query": "^1.167.2",
|
|
20
|
-
"@tanstack/react-start": "^1.168.53",
|
|
21
|
-
"class-variance-authority": "^0.7.1",
|
|
22
|
-
"clsx": "^2.1.1",
|
|
23
|
-
"lucide-react": "^1.41.0",
|
|
24
|
-
"react": "^19.2.8",
|
|
25
|
-
"react-dom": "^19.2.8",
|
|
26
|
-
"srvx": "^1.0.4",
|
|
27
|
-
"tailwind-merge": "^3.6.0"
|
|
28
|
-
},
|
|
29
|
-
"devDependencies": {
|
|
30
|
-
"@eslint/js": "^10.0.1",
|
|
31
|
-
"@tailwindcss/vite": "^4.3.3",
|
|
32
|
-
"@types/node": "^24.13.3",
|
|
33
|
-
"@types/react": "^19.2.18",
|
|
34
|
-
"@types/react-dom": "^19.2.7",
|
|
35
|
-
"@vitejs/plugin-react": "^6.1.1",
|
|
36
|
-
"eslint": "^10.10.0",
|
|
37
|
-
"tailwindcss": "^4.3.3",
|
|
38
|
-
"typescript": "^6.0.3",
|
|
39
|
-
"typescript-eslint": "^8.69.0",
|
|
40
|
-
"vite": "^8.3.0"
|
|
41
|
-
},
|
|
42
|
-
"envVars": {
|
|
43
|
-
"PORT": "3000"
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
"src/routes/index.tsx.hbs"
|
|
47
|
-
]
|
|
48
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"id": "tanstack",
|
|
3
|
+
"name": "TanStack Start",
|
|
4
|
+
"description": "TanStack Start with TanStack Router, React 19, and Tailwind CSS v4",
|
|
5
|
+
"type": "base",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "vite dev",
|
|
8
|
+
"build": "vite build",
|
|
9
|
+
"start": "srvx --prod -s ../client dist/server/server.js",
|
|
10
|
+
"preview": "vite preview",
|
|
11
|
+
"type:check": "tsc --noEmit",
|
|
12
|
+
"lint": "eslint src"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@tanstack/react-query": "^5.102.8",
|
|
16
|
+
"@tanstack/react-query-devtools": "^5.102.8",
|
|
17
|
+
"@tanstack/react-router": "^1.170.36",
|
|
18
|
+
"@tanstack/react-router-devtools": "^1.167.2",
|
|
19
|
+
"@tanstack/react-router-ssr-query": "^1.167.2",
|
|
20
|
+
"@tanstack/react-start": "^1.168.53",
|
|
21
|
+
"class-variance-authority": "^0.7.1",
|
|
22
|
+
"clsx": "^2.1.1",
|
|
23
|
+
"lucide-react": "^1.41.0",
|
|
24
|
+
"react": "^19.2.8",
|
|
25
|
+
"react-dom": "^19.2.8",
|
|
26
|
+
"srvx": "^1.0.4",
|
|
27
|
+
"tailwind-merge": "^3.6.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@eslint/js": "^10.0.1",
|
|
31
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
32
|
+
"@types/node": "^24.13.3",
|
|
33
|
+
"@types/react": "^19.2.18",
|
|
34
|
+
"@types/react-dom": "^19.2.7",
|
|
35
|
+
"@vitejs/plugin-react": "^6.1.1",
|
|
36
|
+
"eslint": "^10.10.0",
|
|
37
|
+
"tailwindcss": "^4.3.3",
|
|
38
|
+
"typescript": "^6.0.3",
|
|
39
|
+
"typescript-eslint": "^8.69.0",
|
|
40
|
+
"vite": "^8.3.0"
|
|
41
|
+
},
|
|
42
|
+
"envVars": {
|
|
43
|
+
"PORT": "3000"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -1,26 +1,20 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "auth",
|
|
3
|
-
"name": "JWT Auth",
|
|
4
|
-
"description": "JWT authentication with bcrypt password hashing",
|
|
5
|
-
"type": "feature",
|
|
6
|
-
"dependencies": {
|
|
7
|
-
"bcrypt": "^6.0.0",
|
|
8
|
-
"jsonwebtoken": "^9.0.3"
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"@types/
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
"src/middleware/requireAuth.ts.hbs",
|
|
22
|
-
"src/services/userStore.ts.hbs",
|
|
23
|
-
"src/routes/auth.ts.hbs",
|
|
24
|
-
"src/controllers/authController.ts.hbs"
|
|
25
|
-
]
|
|
26
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"id": "auth",
|
|
3
|
+
"name": "JWT Auth",
|
|
4
|
+
"description": "JWT authentication with bcrypt password hashing",
|
|
5
|
+
"type": "feature",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"bcrypt": "^6.0.0",
|
|
8
|
+
"jsonwebtoken": "^9.0.3",
|
|
9
|
+
"zod": "^4.6.5"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@types/bcrypt": "^6.0.0",
|
|
13
|
+
"@types/jsonwebtoken": "^9.0.10"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {},
|
|
16
|
+
"envVars": {
|
|
17
|
+
"JWT_SECRET": "please-change-me",
|
|
18
|
+
"JWT_EXPIRES_IN": "1h"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Request, Response } from "express";
|
|
2
2
|
import bcrypt from "bcrypt";
|
|
3
|
+
import { z } from "zod";
|
|
3
4
|
import { signToken } from "../lib/jwt";
|
|
4
5
|
import { AuthenticatedRequest } from "../middleware/requireAuth";
|
|
5
6
|
import {
|
|
@@ -14,54 +15,47 @@ const MIN_PASSWORD_LENGTH = 8;
|
|
|
14
15
|
// bcrypt ignores bytes beyond 72, so longer passwords would be silently truncated
|
|
15
16
|
const MAX_PASSWORD_BYTES = 72;
|
|
16
17
|
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
email
|
|
26
|
-
password
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
18
|
+
// Emails are trimmed and lowercased so sign-in is case-insensitive
|
|
19
|
+
const emailSchema = z
|
|
20
|
+
.string("Email and password are required")
|
|
21
|
+
.trim()
|
|
22
|
+
.toLowerCase()
|
|
23
|
+
.pipe(z.email("Email must be a valid email address"));
|
|
24
|
+
|
|
25
|
+
const registerSchema = z.object({
|
|
26
|
+
email: emailSchema,
|
|
27
|
+
password: z
|
|
28
|
+
.string("Email and password are required")
|
|
29
|
+
.min(
|
|
30
|
+
MIN_PASSWORD_LENGTH,
|
|
31
|
+
`Password must be at least ${MIN_PASSWORD_LENGTH} characters`,
|
|
32
|
+
)
|
|
33
|
+
.refine(
|
|
34
|
+
(password) => Buffer.byteLength(password, "utf8") <= MAX_PASSWORD_BYTES,
|
|
35
|
+
`Password must be at most ${MAX_PASSWORD_BYTES} bytes`,
|
|
36
|
+
),
|
|
37
|
+
name: z.string("Name must be a string").nullish(),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const loginSchema = z.object({
|
|
41
|
+
email: emailSchema,
|
|
42
|
+
password: z.string("Email and password are required").min(1),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// Sends a 400 with the first validation message and returns null when the body is invalid
|
|
46
|
+
function parseBody<T>(schema: z.ZodType<T>, req: Request, res: Response): T | null {
|
|
40
47
|
// Express 5 leaves req.body undefined when the request has no body
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
const password = body.password;
|
|
44
|
-
const name = body.name ?? null;
|
|
45
|
-
|
|
46
|
-
if (!email || typeof password !== "string" || !password) {
|
|
47
|
-
res.status(400).json({ error: "Email and password are required" });
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
48
|
+
const result = schema.safeParse(req.body ?? {});
|
|
49
|
+
if (result.success) return result.data;
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
) {
|
|
55
|
-
res.status(400).json({
|
|
56
|
-
error: `Password must be at least ${MIN_PASSWORD_LENGTH} characters and at most ${MAX_PASSWORD_BYTES} bytes`,
|
|
57
|
-
});
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
51
|
+
res.status(400).json({ error: result.error.issues[0].message });
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
60
54
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
55
|
+
export async function register(req: Request, res: Response): Promise<void> {
|
|
56
|
+
const body = parseBody(registerSchema, req, res);
|
|
57
|
+
if (!body) return;
|
|
58
|
+
const { email, password, name = null } = body;
|
|
65
59
|
|
|
66
60
|
const existingUser = await findUserByEmail(email);
|
|
67
61
|
if (existingUser) {
|
|
@@ -88,18 +82,10 @@ export async function register(
|
|
|
88
82
|
res.status(201).json({ user: toPublicUser(user), token });
|
|
89
83
|
}
|
|
90
84
|
|
|
91
|
-
export async function login(
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const body = req.body ?? {};
|
|
96
|
-
const email = normalizeEmail(body.email);
|
|
97
|
-
const password = body.password;
|
|
98
|
-
|
|
99
|
-
if (!email || typeof password !== "string" || !password) {
|
|
100
|
-
res.status(400).json({ error: "Email and password are required" });
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
85
|
+
export async function login(req: Request, res: Response): Promise<void> {
|
|
86
|
+
const body = parseBody(loginSchema, req, res);
|
|
87
|
+
if (!body) return;
|
|
88
|
+
const { email, password } = body;
|
|
103
89
|
|
|
104
90
|
const user = await findUserByEmail(email);
|
|
105
91
|
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "auth",
|
|
3
|
+
"name": "JWT Auth",
|
|
4
|
+
"description": "JWT authentication for NestJS with bcrypt password hashing",
|
|
5
|
+
"type": "feature",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"bcrypt": "^6.0.0",
|
|
8
|
+
"jsonwebtoken": "^9.0.3",
|
|
9
|
+
"zod": "^4.6.5"
|
|
10
|
+
},
|
|
11
|
+
"devDependencies": {
|
|
12
|
+
"@types/bcrypt": "^6.0.0",
|
|
13
|
+
"@types/jsonwebtoken": "^9.0.10"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {},
|
|
16
|
+
"envVars": {
|
|
17
|
+
"JWT_SECRET": "please-change-me",
|
|
18
|
+
"JWT_EXPIRES_IN": "1h"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Body,
|
|
3
|
+
Controller,
|
|
4
|
+
Get,
|
|
5
|
+
HttpCode,
|
|
6
|
+
Post,
|
|
7
|
+
Req,
|
|
8
|
+
UseGuards,
|
|
9
|
+
} from "@nestjs/common";
|
|
10
|
+
|
|
11
|
+
import { type AuthenticatedRequest, AuthGuard } from "./auth.guard.js";
|
|
12
|
+
import {
|
|
13
|
+
type LoginInput,
|
|
14
|
+
loginSchema,
|
|
15
|
+
type RegisterInput,
|
|
16
|
+
registerSchema,
|
|
17
|
+
} from "./auth.schemas.js";
|
|
18
|
+
import { type AuthResponse, AuthService } from "./auth.service.js";
|
|
19
|
+
import type { AuthTokenPayload } from "./jwt.js";
|
|
20
|
+
import { ZodValidationPipe } from "./zod-validation.pipe.js";
|
|
21
|
+
|
|
22
|
+
@Controller("auth")
|
|
23
|
+
export class AuthController {
|
|
24
|
+
constructor(private readonly authService: AuthService) {}
|
|
25
|
+
|
|
26
|
+
@Post("register")
|
|
27
|
+
register(
|
|
28
|
+
@Body(new ZodValidationPipe(registerSchema)) body: RegisterInput
|
|
29
|
+
): Promise<AuthResponse> {
|
|
30
|
+
return this.authService.register(body);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@Post("login")
|
|
34
|
+
@HttpCode(200)
|
|
35
|
+
login(
|
|
36
|
+
@Body(new ZodValidationPipe(loginSchema)) body: LoginInput
|
|
37
|
+
): Promise<AuthResponse> {
|
|
38
|
+
return this.authService.login(body);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@Get("me")
|
|
42
|
+
@UseGuards(AuthGuard)
|
|
43
|
+
me(@Req() request: AuthenticatedRequest): { user?: AuthTokenPayload } {
|
|
44
|
+
return { user: request.user };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CanActivate,
|
|
3
|
+
type ExecutionContext,
|
|
4
|
+
Injectable,
|
|
5
|
+
UnauthorizedException,
|
|
6
|
+
} from "@nestjs/common";
|
|
7
|
+
import type { Request } from "express";
|
|
8
|
+
|
|
9
|
+
import { type AuthTokenPayload, verifyToken } from "./jwt.js";
|
|
10
|
+
|
|
11
|
+
export interface AuthenticatedRequest extends Request {
|
|
12
|
+
user?: AuthTokenPayload;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Requires a valid "Authorization: Bearer <token>" header and sets request.user
|
|
16
|
+
@Injectable()
|
|
17
|
+
export class AuthGuard implements CanActivate {
|
|
18
|
+
canActivate(context: ExecutionContext): boolean {
|
|
19
|
+
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
|
20
|
+
const authHeader = request.headers.authorization;
|
|
21
|
+
|
|
22
|
+
if (!authHeader?.startsWith("Bearer ")) {
|
|
23
|
+
throw new UnauthorizedException(
|
|
24
|
+
"Authorization header missing or invalid"
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
request.user = verifyToken(authHeader.substring("Bearer ".length));
|
|
30
|
+
} catch {
|
|
31
|
+
throw new UnauthorizedException("Invalid or expired token");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Module } from "@nestjs/common";
|
|
2
|
+
{{#if (eq database 'mongoose')}}
|
|
3
|
+
import { MongooseModule } from "@nestjs/mongoose";
|
|
4
|
+
{{/if}}
|
|
5
|
+
|
|
6
|
+
{{#if (eq database 'mongoose')}}
|
|
7
|
+
import { User, UserSchema } from "../users/user.schema.js";
|
|
8
|
+
{{/if}}
|
|
9
|
+
import { AuthController } from "./auth.controller.js";
|
|
10
|
+
import { AuthService } from "./auth.service.js";
|
|
11
|
+
import { UsersService } from "./users.service.js";
|
|
12
|
+
|
|
13
|
+
@Module({
|
|
14
|
+
{{#if (eq database 'mongoose')}}
|
|
15
|
+
imports: [
|
|
16
|
+
MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
|
|
17
|
+
],
|
|
18
|
+
{{/if}}
|
|
19
|
+
controllers: [AuthController],
|
|
20
|
+
providers: [AuthService, UsersService],
|
|
21
|
+
})
|
|
22
|
+
export class AuthModule {}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
const MIN_PASSWORD_LENGTH = 8;
|
|
4
|
+
// bcrypt ignores bytes beyond 72, so longer passwords would be silently truncated
|
|
5
|
+
const MAX_PASSWORD_BYTES = 72;
|
|
6
|
+
|
|
7
|
+
// Emails are trimmed and lowercased so sign-in is case-insensitive
|
|
8
|
+
const emailSchema = z
|
|
9
|
+
.string("Email and password are required")
|
|
10
|
+
.trim()
|
|
11
|
+
.toLowerCase()
|
|
12
|
+
.pipe(z.email("Email must be a valid email address"));
|
|
13
|
+
|
|
14
|
+
export const registerSchema = z.object({
|
|
15
|
+
email: emailSchema,
|
|
16
|
+
password: z
|
|
17
|
+
.string("Email and password are required")
|
|
18
|
+
.min(
|
|
19
|
+
MIN_PASSWORD_LENGTH,
|
|
20
|
+
`Password must be at least ${MIN_PASSWORD_LENGTH} characters`
|
|
21
|
+
)
|
|
22
|
+
.refine(
|
|
23
|
+
(password) => Buffer.byteLength(password, "utf8") <= MAX_PASSWORD_BYTES,
|
|
24
|
+
`Password must be at most ${MAX_PASSWORD_BYTES} bytes`
|
|
25
|
+
),
|
|
26
|
+
name: z.string("Name must be a string").nullish(),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const loginSchema = z.object({
|
|
30
|
+
email: emailSchema,
|
|
31
|
+
password: z.string("Email and password are required").min(1),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export type RegisterInput = z.infer<typeof registerSchema>;
|
|
35
|
+
export type LoginInput = z.infer<typeof loginSchema>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConflictException,
|
|
3
|
+
Injectable,
|
|
4
|
+
UnauthorizedException,
|
|
5
|
+
} from "@nestjs/common";
|
|
6
|
+
import bcrypt from "bcrypt";
|
|
7
|
+
|
|
8
|
+
import type { LoginInput, RegisterInput } from "./auth.schemas.js";
|
|
9
|
+
import { signToken } from "./jwt.js";
|
|
10
|
+
import {
|
|
11
|
+
type AuthUser,
|
|
12
|
+
isDuplicateEmailError,
|
|
13
|
+
UsersService,
|
|
14
|
+
} from "./users.service.js";
|
|
15
|
+
|
|
16
|
+
const SALT_ROUNDS = 10;
|
|
17
|
+
|
|
18
|
+
export interface AuthResponse {
|
|
19
|
+
user: Omit<AuthUser, "password">;
|
|
20
|
+
token: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@Injectable()
|
|
24
|
+
export class AuthService {
|
|
25
|
+
constructor(private readonly usersService: UsersService) {}
|
|
26
|
+
|
|
27
|
+
async register({
|
|
28
|
+
email,
|
|
29
|
+
password,
|
|
30
|
+
name = null,
|
|
31
|
+
}: RegisterInput): Promise<AuthResponse> {
|
|
32
|
+
if (await this.usersService.findByEmail(email)) {
|
|
33
|
+
throw new ConflictException("User already exists");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const user = await this.usersService.create({
|
|
40
|
+
email,
|
|
41
|
+
name,
|
|
42
|
+
password: hashedPassword,
|
|
43
|
+
});
|
|
44
|
+
return this.toAuthResponse(user);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
// A concurrent request registered the same email after the check above
|
|
47
|
+
if (isDuplicateEmailError(error)) {
|
|
48
|
+
throw new ConflictException("User already exists");
|
|
49
|
+
}
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async login({ email, password }: LoginInput): Promise<AuthResponse> {
|
|
55
|
+
const user = await this.usersService.findByEmail(email);
|
|
56
|
+
|
|
57
|
+
if (!user || !(await bcrypt.compare(password, user.password))) {
|
|
58
|
+
throw new UnauthorizedException("Invalid credentials");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return this.toAuthResponse(user);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private toAuthResponse({ id, email, name }: AuthUser): AuthResponse {
|
|
65
|
+
return {
|
|
66
|
+
user: { id, email, name },
|
|
67
|
+
token: signToken({ sub: id, email, name }),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import jwt, { type SignOptions } from "jsonwebtoken";
|
|
2
|
+
|
|
3
|
+
export interface AuthTokenPayload {
|
|
4
|
+
sub: string;
|
|
5
|
+
email: string;
|
|
6
|
+
name?: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const { JWT_SECRET, JWT_EXPIRES_IN } = process.env;
|
|
10
|
+
|
|
11
|
+
function ensureSecret(): string {
|
|
12
|
+
if (!JWT_SECRET) {
|
|
13
|
+
throw new Error("JWT_SECRET is not set");
|
|
14
|
+
}
|
|
15
|
+
return JWT_SECRET;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function signToken(payload: AuthTokenPayload): string {
|
|
19
|
+
const secret = ensureSecret();
|
|
20
|
+
const expiresIn = JWT_EXPIRES_IN || "1h";
|
|
21
|
+
return jwt.sign(payload, secret, { expiresIn } as SignOptions);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function verifyToken(token: string): AuthTokenPayload {
|
|
25
|
+
const secret = ensureSecret();
|
|
26
|
+
return jwt.verify(token, secret) as AuthTokenPayload;
|
|
27
|
+
}
|