better-ts-stack 2.1.2 → 2.2.0
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/README.md +16 -8
- package/dist/args.js +2 -1
- package/dist/builder/buildPlanner.js +7 -1
- package/dist/builder/configGenerator.js +28 -3
- package/dist/builder/index.js +2 -1
- package/dist/builder/readmeGenerator.js +2 -2
- package/dist/output/nextSteps.js +2 -4
- package/dist/prompts/stack.js +3 -5
- package/dist/types/index.js +1 -1
- package/package.json +1 -1
- package/templates/backend/express/src/env.ts.hbs +13 -2
- package/templates/backend/express/src/index.ts.hbs +31 -2
- package/templates/backend/nestjs/src/app.module.ts.hbs +4 -1
- package/templates/backend/nestjs/src/env.ts.hbs +13 -2
- package/templates/backend/nestjs/src/health/health.controller.ts.hbs +7 -0
- package/templates/backend/nestjs/src/{main.ts → main.ts.hbs} +11 -0
- package/templates/frontend/nextjs/app/globals.css +119 -79
- package/templates/frontend/nextjs/app/layout.tsx +13 -11
- package/templates/frontend/nextjs/app/page.tsx.hbs +63 -43
- package/templates/frontend/nextjs/components/ui/alert.tsx +75 -0
- package/templates/frontend/nextjs/components/ui/badge.tsx +51 -0
- package/templates/frontend/nextjs/components/ui/button.tsx +33 -27
- package/templates/frontend/nextjs/components/ui/card.tsx +76 -34
- package/templates/frontend/nextjs/components/ui/field.tsx +203 -32
- package/templates/frontend/nextjs/components/ui/input.tsx +8 -7
- package/templates/frontend/nextjs/components/ui/label.tsx +19 -0
- package/templates/frontend/nextjs/components/ui/separator.tsx +24 -0
- package/templates/frontend/nextjs/components/ui/skeleton.tsx +13 -0
- package/templates/frontend/nextjs/components/ui/spinner.tsx +10 -0
- package/templates/frontend/nextjs/components.json +11 -5
- package/templates/frontend/nextjs/config.json +4 -3
- package/templates/frontend/nextjs/lib/utils.ts +1 -6
- package/templates/frontend/tanstack/components.json +12 -5
- package/templates/frontend/tanstack/config.json +5 -2
- package/templates/frontend/tanstack/src/components/ui/alert.tsx +75 -0
- package/templates/frontend/tanstack/src/components/ui/badge.tsx +51 -0
- package/templates/frontend/tanstack/src/components/ui/button.tsx +33 -27
- package/templates/frontend/tanstack/src/components/ui/card.tsx +76 -34
- package/templates/frontend/tanstack/src/components/ui/field.tsx +201 -32
- package/templates/frontend/tanstack/src/components/ui/input.tsx +8 -7
- package/templates/frontend/tanstack/src/components/ui/label.tsx +17 -0
- package/templates/frontend/tanstack/src/components/ui/separator.tsx +24 -0
- package/templates/frontend/tanstack/src/components/ui/skeleton.tsx +13 -0
- package/templates/frontend/tanstack/src/components/ui/spinner.tsx +10 -0
- package/templates/frontend/tanstack/src/lib/utils.ts +1 -6
- package/templates/frontend/tanstack/src/routes/index.tsx.hbs +64 -45
- package/templates/frontend/tanstack/src/styles/app.css +100 -55
- package/templates/modules/agents/AGENTS.md.hbs +29 -21
- package/templates/modules/auth/express/config.json +12 -11
- package/templates/modules/auth/express/src/lib/auth.ts.hbs +143 -0
- package/templates/modules/auth/express/src/lib/email.ts +37 -0
- package/templates/modules/auth/express/src/middleware/requireAuth.ts +29 -0
- package/templates/modules/auth/express/src/routes/me.ts +13 -0
- package/templates/modules/auth/nestjs/config.json +13 -11
- package/templates/modules/auth/nestjs/src/auth/auth.module.ts.hbs +64 -14
- package/templates/modules/auth/nestjs/src/auth/auth.ts.hbs +152 -0
- package/templates/modules/auth/nestjs/src/auth/email.ts +37 -0
- package/templates/modules/auth/nestjs/src/me/me.controller.ts +15 -0
- package/templates/modules/auth/nextjs/app/dashboard/page.tsx +101 -45
- package/templates/modules/auth/nextjs/app/forgot-password/page.tsx +11 -0
- package/templates/modules/auth/nextjs/app/reset-password/page.tsx +17 -0
- package/templates/modules/auth/nextjs/app/sign-in/page.tsx +23 -17
- package/templates/modules/auth/nextjs/app/sign-up/page.tsx +25 -19
- package/templates/modules/auth/nextjs/components/auth/change-password-form.tsx +125 -0
- package/templates/modules/auth/nextjs/components/auth/email-verification-card.tsx +91 -0
- package/templates/modules/auth/nextjs/components/auth/forgot-password-form.tsx +133 -0
- package/templates/modules/auth/nextjs/components/auth/reset-password-form.tsx +155 -0
- package/templates/modules/auth/nextjs/components/auth/sign-in-form.tsx +46 -32
- package/templates/modules/auth/nextjs/components/auth/sign-out-button.tsx +6 -5
- package/templates/modules/auth/nextjs/components/auth/sign-up-form.tsx +36 -30
- package/templates/modules/auth/nextjs/components/auth/social-buttons.tsx +73 -0
- package/templates/modules/auth/nextjs/config.json +8 -2
- package/templates/modules/auth/nextjs/lib/auth-schema.ts +27 -2
- package/templates/modules/auth/nextjs/lib/auth.ts.hbs +65 -0
- package/templates/modules/auth/nextjs/lib/email.ts +43 -0
- package/templates/modules/auth/nextjs/lib/session.ts +12 -0
- package/templates/modules/auth/tanstack/config.json +9 -3
- package/templates/modules/auth/tanstack/src/components/auth/change-password-form.tsx +123 -0
- package/templates/modules/auth/tanstack/src/components/auth/email-verification-card.tsx +89 -0
- package/templates/modules/auth/tanstack/src/components/auth/forgot-password-form.tsx +131 -0
- package/templates/modules/auth/tanstack/src/components/auth/reset-password-form.tsx +149 -0
- package/templates/modules/auth/tanstack/src/components/auth/sign-in-form.tsx +44 -35
- package/templates/modules/auth/tanstack/src/components/auth/sign-out-button.tsx +7 -6
- package/templates/modules/auth/tanstack/src/components/auth/sign-up-form.tsx +37 -34
- package/templates/modules/auth/tanstack/src/components/auth/social-buttons.tsx +71 -0
- package/templates/modules/auth/tanstack/src/lib/auth-functions.ts +7 -1
- package/templates/modules/auth/tanstack/src/lib/auth-schema.ts +27 -2
- package/templates/modules/auth/tanstack/src/lib/auth.ts.hbs +61 -1
- package/templates/modules/auth/tanstack/src/lib/email.ts +43 -0
- package/templates/modules/auth/tanstack/src/routeTree.gen.ts +61 -3
- package/templates/modules/auth/tanstack/src/routes/dashboard.tsx +40 -32
- package/templates/modules/auth/tanstack/src/routes/forgot-password.tsx +17 -0
- package/templates/modules/auth/tanstack/src/routes/reset-password.tsx +25 -0
- package/templates/modules/auth/tanstack/src/routes/sign-in.tsx +19 -12
- package/templates/modules/auth/tanstack/src/routes/sign-up.tsx +21 -14
- package/templates/modules/drizzle/express/src/lib/schema.ts.hbs +90 -0
- package/templates/modules/drizzle/nestjs/src/database/schema.ts.hbs +90 -0
- package/templates/modules/mongoose/nestjs/config.json +2 -1
- package/templates/modules/prisma/express/prisma/schema.prisma.hbs +92 -0
- package/templates/modules/prisma/nestjs/prisma/schema.prisma.hbs +91 -0
- package/templates/modules/rate-limit/express/src/middleware/rateLimit.ts +0 -8
- package/templates/modules/auth/express/src/controllers/authController.ts.hbs +0 -116
- package/templates/modules/auth/express/src/lib/jwt.ts.hbs +0 -27
- package/templates/modules/auth/express/src/middleware/requireAuth.ts.hbs +0 -25
- package/templates/modules/auth/express/src/routes/auth.ts.hbs +0 -27
- package/templates/modules/auth/express/src/services/userStore.ts.hbs +0 -117
- package/templates/modules/auth/nestjs/src/auth/auth.controller.ts.hbs +0 -61
- package/templates/modules/auth/nestjs/src/auth/auth.guard.ts +0 -36
- package/templates/modules/auth/nestjs/src/auth/auth.schemas.ts +0 -35
- package/templates/modules/auth/nestjs/src/auth/auth.service.ts +0 -70
- package/templates/modules/auth/nestjs/src/auth/jwt.ts +0 -27
- package/templates/modules/auth/nestjs/src/auth/users.service.ts.hbs +0 -144
- package/templates/modules/auth/nestjs/src/auth/zod-validation.pipe.ts +0 -16
- package/templates/modules/drizzle/express/src/lib/schema.ts +0 -12
- package/templates/modules/drizzle/nestjs/src/database/schema.ts +0 -12
- package/templates/modules/prisma/express/prisma/schema.prisma +0 -24
- package/templates/modules/prisma/nestjs/prisma/schema.prisma +0 -23
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { NextFunction, Request, Response } from 'express';
|
|
2
|
-
import { AuthTokenPayload, verifyToken } from '../lib/jwt';
|
|
3
|
-
|
|
4
|
-
export interface AuthenticatedRequest extends Request {
|
|
5
|
-
user?: AuthTokenPayload;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export function requireAuth(req: AuthenticatedRequest, res: Response, next: NextFunction): void {
|
|
9
|
-
const authHeader = req.headers.authorization;
|
|
10
|
-
|
|
11
|
-
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
|
12
|
-
res.status(401).json({ error: 'Authorization header missing or invalid' });
|
|
13
|
-
return;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const token = authHeader.substring('Bearer '.length);
|
|
17
|
-
|
|
18
|
-
try {
|
|
19
|
-
const payload = verifyToken(token);
|
|
20
|
-
req.user = payload;
|
|
21
|
-
next();
|
|
22
|
-
} catch {
|
|
23
|
-
res.status(401).json({ error: 'Invalid or expired token' });
|
|
24
|
-
}
|
|
25
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { Request, Response, NextFunction, Router } from 'express';
|
|
2
|
-
import { login, me, register } from '../controllers/authController';
|
|
3
|
-
{{#if useRateLimit}}
|
|
4
|
-
import { authLimiter } from '../middleware/rateLimit';
|
|
5
|
-
{{/if}}
|
|
6
|
-
import { requireAuth } from '../middleware/requireAuth';
|
|
7
|
-
|
|
8
|
-
const router: Router = Router();
|
|
9
|
-
|
|
10
|
-
function asyncHandler(
|
|
11
|
-
fn: (req: Request, res: Response, next: NextFunction) => Promise<void>
|
|
12
|
-
): (req: Request, res: Response, next: NextFunction) => void {
|
|
13
|
-
return (req, res, next) => {
|
|
14
|
-
void fn(req, res, next).catch(next);
|
|
15
|
-
};
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
{{#if useRateLimit}}
|
|
19
|
-
router.post('/register', authLimiter, asyncHandler(register));
|
|
20
|
-
router.post('/login', authLimiter, asyncHandler(login));
|
|
21
|
-
{{else}}
|
|
22
|
-
router.post('/register', asyncHandler(register));
|
|
23
|
-
router.post('/login', asyncHandler(login));
|
|
24
|
-
{{/if}}
|
|
25
|
-
router.get('/me', requireAuth, me);
|
|
26
|
-
|
|
27
|
-
export default router;
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
{{#if (eq database 'drizzle')}}
|
|
2
|
-
import { eq } from "drizzle-orm";
|
|
3
|
-
import { db } from "../lib/db";
|
|
4
|
-
import { users } from "../lib/schema";
|
|
5
|
-
{{/if}}
|
|
6
|
-
{{#if (eq database 'none')}}
|
|
7
|
-
import crypto from 'crypto';
|
|
8
|
-
{{/if}}
|
|
9
|
-
{{#if (eq database 'prisma')}}
|
|
10
|
-
import { prisma } from '../lib/prisma';
|
|
11
|
-
{{/if}}
|
|
12
|
-
{{#if (eq database 'mongoose')}}
|
|
13
|
-
import UserModel from '../models/User';
|
|
14
|
-
{{/if}}
|
|
15
|
-
|
|
16
|
-
export interface AuthUser {
|
|
17
|
-
id: string;
|
|
18
|
-
email: string;
|
|
19
|
-
name?: string | null;
|
|
20
|
-
password: string;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export interface CreateUserInput {
|
|
24
|
-
email: string;
|
|
25
|
-
name?: string | null;
|
|
26
|
-
password: string;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
{{#if (eq database 'none')}}
|
|
30
|
-
const users: AuthUser[] = [];
|
|
31
|
-
{{/if}}
|
|
32
|
-
|
|
33
|
-
export {{#unless (eq database 'none')}}async {{/unless}}function findUserByEmail(email: string): Promise<AuthUser | null> {
|
|
34
|
-
{{#if (eq database 'drizzle')}}
|
|
35
|
-
const [user] = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
|
36
|
-
return user ?? null;
|
|
37
|
-
{{/if}}
|
|
38
|
-
{{#if (eq database 'prisma')}}
|
|
39
|
-
return await prisma.user.findUnique({ where: { email } });
|
|
40
|
-
{{/if}}
|
|
41
|
-
|
|
42
|
-
{{#if (eq database 'mongoose')}}
|
|
43
|
-
const user = await UserModel.findOne({ email }).exec();
|
|
44
|
-
if (!user) return null;
|
|
45
|
-
return {
|
|
46
|
-
id: String(user._id),
|
|
47
|
-
email: user.email,
|
|
48
|
-
name: user.name,
|
|
49
|
-
password: user.password,
|
|
50
|
-
};
|
|
51
|
-
{{/if}}
|
|
52
|
-
|
|
53
|
-
{{#if (eq database 'none')}}
|
|
54
|
-
const user = users.find((candidate) => candidate.email === email);
|
|
55
|
-
return Promise.resolve(user ?? null);
|
|
56
|
-
{{/if}}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export {{#unless (eq database 'none')}}async {{/unless}}function createUser(data: CreateUserInput): Promise<AuthUser> {
|
|
60
|
-
{{#if (eq database 'drizzle')}}
|
|
61
|
-
const [user] = await db.insert(users).values(data).returning();
|
|
62
|
-
return user;
|
|
63
|
-
{{/if}}
|
|
64
|
-
{{#if (eq database 'prisma')}}
|
|
65
|
-
return await prisma.user.create({ data });
|
|
66
|
-
{{/if}}
|
|
67
|
-
|
|
68
|
-
{{#if (eq database 'mongoose')}}
|
|
69
|
-
const user = await UserModel.create(data);
|
|
70
|
-
|
|
71
|
-
return {
|
|
72
|
-
id: String(user._id),
|
|
73
|
-
email: user.email,
|
|
74
|
-
name: user.name,
|
|
75
|
-
password: user.password,
|
|
76
|
-
};
|
|
77
|
-
{{/if}}
|
|
78
|
-
|
|
79
|
-
{{#if (eq database 'none')}}
|
|
80
|
-
// Enforce unique emails like a database constraint would, so a sign-up that
|
|
81
|
-
// raced past the findUserByEmail check is rejected instead of duplicated
|
|
82
|
-
if (users.some((candidate) => candidate.email === data.email)) {
|
|
83
|
-
return Promise.reject(
|
|
84
|
-
Object.assign(new Error('User already exists'), { code: 'DUPLICATE_EMAIL' }),
|
|
85
|
-
);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const newUser: AuthUser = {
|
|
89
|
-
id: crypto.randomUUID(),
|
|
90
|
-
email: data.email,
|
|
91
|
-
name: data.name,
|
|
92
|
-
password: data.password,
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
users.push(newUser);
|
|
96
|
-
return Promise.resolve(newUser);
|
|
97
|
-
{{/if}}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Unique-constraint error codes: Prisma, PostgreSQL (Drizzle wraps it in `cause`),
|
|
101
|
-
// MongoDB, and the in-memory store
|
|
102
|
-
const DUPLICATE_KEY_CODES = new Set<unknown>(['P2002', '23505', 11000, 'DUPLICATE_EMAIL']);
|
|
103
|
-
|
|
104
|
-
// True when an insert failed because the email is already taken (e.g. concurrent sign-ups)
|
|
105
|
-
export function isDuplicateEmailError(error: unknown): boolean {
|
|
106
|
-
let current = error;
|
|
107
|
-
for (let depth = 0; depth < 5 && current && typeof current === 'object'; depth++) {
|
|
108
|
-
if (DUPLICATE_KEY_CODES.has((current as { code?: unknown }).code)) return true;
|
|
109
|
-
current = (current as { cause?: unknown }).cause;
|
|
110
|
-
}
|
|
111
|
-
return false;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
export function toPublicUser(user: AuthUser): Omit<AuthUser, 'password'> {
|
|
115
|
-
const { id, email, name } = user;
|
|
116
|
-
return { id, email, name };
|
|
117
|
-
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Body,
|
|
3
|
-
Controller,
|
|
4
|
-
Get,
|
|
5
|
-
HttpCode,
|
|
6
|
-
Post,
|
|
7
|
-
Req,
|
|
8
|
-
UseGuards,
|
|
9
|
-
} from "@nestjs/common";
|
|
10
|
-
{{#if useRateLimit}}
|
|
11
|
-
import { Throttle } from "@nestjs/throttler";
|
|
12
|
-
{{/if}}
|
|
13
|
-
|
|
14
|
-
import { type AuthenticatedRequest, AuthGuard } from "./auth.guard.js";
|
|
15
|
-
import {
|
|
16
|
-
type LoginInput,
|
|
17
|
-
loginSchema,
|
|
18
|
-
type RegisterInput,
|
|
19
|
-
registerSchema,
|
|
20
|
-
} from "./auth.schemas.js";
|
|
21
|
-
import { type AuthResponse, AuthService } from "./auth.service.js";
|
|
22
|
-
import type { AuthTokenPayload } from "./jwt.js";
|
|
23
|
-
import { ZodValidationPipe } from "./zod-validation.pipe.js";
|
|
24
|
-
|
|
25
|
-
{{#if useRateLimit}}
|
|
26
|
-
// Sign-up and login: 10 attempts per client IP in 15 minutes, against
|
|
27
|
-
// password guessing
|
|
28
|
-
const AUTH_LIMIT = { default: { ttl: 15 * 60 * 1000, limit: 10 } };
|
|
29
|
-
|
|
30
|
-
{{/if}}
|
|
31
|
-
@Controller("auth")
|
|
32
|
-
export class AuthController {
|
|
33
|
-
constructor(private readonly authService: AuthService) {}
|
|
34
|
-
|
|
35
|
-
@Post("register")
|
|
36
|
-
{{#if useRateLimit}}
|
|
37
|
-
@Throttle(AUTH_LIMIT)
|
|
38
|
-
{{/if}}
|
|
39
|
-
register(
|
|
40
|
-
@Body(new ZodValidationPipe(registerSchema)) body: RegisterInput
|
|
41
|
-
): Promise<AuthResponse> {
|
|
42
|
-
return this.authService.register(body);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
@Post("login")
|
|
46
|
-
@HttpCode(200)
|
|
47
|
-
{{#if useRateLimit}}
|
|
48
|
-
@Throttle(AUTH_LIMIT)
|
|
49
|
-
{{/if}}
|
|
50
|
-
login(
|
|
51
|
-
@Body(new ZodValidationPipe(loginSchema)) body: LoginInput
|
|
52
|
-
): Promise<AuthResponse> {
|
|
53
|
-
return this.authService.login(body);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
@Get("me")
|
|
57
|
-
@UseGuards(AuthGuard)
|
|
58
|
-
me(@Req() request: AuthenticatedRequest): { user?: AuthTokenPayload } {
|
|
59
|
-
return { user: request.user };
|
|
60
|
-
}
|
|
61
|
-
}
|
|
@@ -1,36 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,35 +0,0 @@
|
|
|
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>;
|
|
@@ -1,70 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
{{#if (eq database 'drizzle')}}
|
|
2
|
-
import { Inject, Injectable } from "@nestjs/common";
|
|
3
|
-
import { eq } from "drizzle-orm";
|
|
4
|
-
|
|
5
|
-
import { type Database, DRIZZLE } from "../database/database.module.js";
|
|
6
|
-
import { users } from "../database/schema.js";
|
|
7
|
-
{{/if}}
|
|
8
|
-
{{#if (eq database 'prisma')}}
|
|
9
|
-
import { Injectable } from "@nestjs/common";
|
|
10
|
-
|
|
11
|
-
import { PrismaService } from "../prisma/prisma.service.js";
|
|
12
|
-
{{/if}}
|
|
13
|
-
{{#if (eq database 'mongoose')}}
|
|
14
|
-
import { Injectable } from "@nestjs/common";
|
|
15
|
-
import { InjectModel } from "@nestjs/mongoose";
|
|
16
|
-
import type { Model } from "mongoose";
|
|
17
|
-
|
|
18
|
-
import { User } from "../users/user.schema.js";
|
|
19
|
-
{{/if}}
|
|
20
|
-
{{#if (eq database 'none')}}
|
|
21
|
-
import { Injectable } from "@nestjs/common";
|
|
22
|
-
import { randomUUID } from "node:crypto";
|
|
23
|
-
{{/if}}
|
|
24
|
-
|
|
25
|
-
export interface AuthUser {
|
|
26
|
-
id: string;
|
|
27
|
-
email: string;
|
|
28
|
-
name?: string | null;
|
|
29
|
-
password: string;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface CreateUserInput {
|
|
33
|
-
email: string;
|
|
34
|
-
name?: string | null;
|
|
35
|
-
password: string;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
@Injectable()
|
|
39
|
-
export class UsersService {
|
|
40
|
-
{{#if (eq database 'drizzle')}}
|
|
41
|
-
constructor(@Inject(DRIZZLE) private readonly db: Database) {}
|
|
42
|
-
|
|
43
|
-
async findByEmail(email: string): Promise<AuthUser | null> {
|
|
44
|
-
const [user] = await this.db
|
|
45
|
-
.select()
|
|
46
|
-
.from(users)
|
|
47
|
-
.where(eq(users.email, email))
|
|
48
|
-
.limit(1);
|
|
49
|
-
return user ?? null;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
async create(data: CreateUserInput): Promise<AuthUser> {
|
|
53
|
-
const [user] = await this.db.insert(users).values(data).returning();
|
|
54
|
-
return user;
|
|
55
|
-
}
|
|
56
|
-
{{/if}}
|
|
57
|
-
{{#if (eq database 'prisma')}}
|
|
58
|
-
constructor(private readonly prisma: PrismaService) {}
|
|
59
|
-
|
|
60
|
-
findByEmail(email: string): Promise<AuthUser | null> {
|
|
61
|
-
return this.prisma.user.findUnique({ where: { email } });
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
create(data: CreateUserInput): Promise<AuthUser> {
|
|
65
|
-
return this.prisma.user.create({ data });
|
|
66
|
-
}
|
|
67
|
-
{{/if}}
|
|
68
|
-
{{#if (eq database 'mongoose')}}
|
|
69
|
-
constructor(
|
|
70
|
-
@InjectModel(User.name) private readonly userModel: Model<User>
|
|
71
|
-
) {}
|
|
72
|
-
|
|
73
|
-
async findByEmail(email: string): Promise<AuthUser | null> {
|
|
74
|
-
const user = await this.userModel.findOne({ email }).exec();
|
|
75
|
-
if (!user) return null;
|
|
76
|
-
return {
|
|
77
|
-
id: user._id.toString(),
|
|
78
|
-
email: user.email,
|
|
79
|
-
name: user.name,
|
|
80
|
-
password: user.password,
|
|
81
|
-
};
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
async create(data: CreateUserInput): Promise<AuthUser> {
|
|
85
|
-
const user = await this.userModel.create(data);
|
|
86
|
-
return {
|
|
87
|
-
id: user._id.toString(),
|
|
88
|
-
email: user.email,
|
|
89
|
-
name: user.name,
|
|
90
|
-
password: user.password,
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
{{/if}}
|
|
94
|
-
{{#if (eq database 'none')}}
|
|
95
|
-
// In-memory store for trying the API out: users are lost on restart, so add
|
|
96
|
-
// a database before going to production
|
|
97
|
-
private readonly users: AuthUser[] = [];
|
|
98
|
-
|
|
99
|
-
findByEmail(email: string): Promise<AuthUser | null> {
|
|
100
|
-
const user = this.users.find((candidate) => candidate.email === email);
|
|
101
|
-
return Promise.resolve(user ?? null);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
create(data: CreateUserInput): Promise<AuthUser> {
|
|
105
|
-
// Enforce unique emails like a database constraint would, so a sign-up that
|
|
106
|
-
// raced past the findByEmail check is rejected instead of duplicated
|
|
107
|
-
if (this.users.some((candidate) => candidate.email === data.email)) {
|
|
108
|
-
return Promise.reject(
|
|
109
|
-
Object.assign(new Error("User already exists"), {
|
|
110
|
-
code: "DUPLICATE_EMAIL",
|
|
111
|
-
})
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const user: AuthUser = { id: randomUUID(), ...data };
|
|
116
|
-
this.users.push(user);
|
|
117
|
-
return Promise.resolve(user);
|
|
118
|
-
}
|
|
119
|
-
{{/if}}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Unique-constraint error codes: Prisma, PostgreSQL (Drizzle wraps it in `cause`),
|
|
123
|
-
// MongoDB, and the in-memory store
|
|
124
|
-
const DUPLICATE_KEY_CODES = new Set<unknown>([
|
|
125
|
-
"P2002",
|
|
126
|
-
"23505",
|
|
127
|
-
11000,
|
|
128
|
-
"DUPLICATE_EMAIL",
|
|
129
|
-
]);
|
|
130
|
-
|
|
131
|
-
// True when an insert failed because the email is already taken (e.g. concurrent sign-ups)
|
|
132
|
-
export function isDuplicateEmailError(error: unknown): boolean {
|
|
133
|
-
let current = error;
|
|
134
|
-
for (
|
|
135
|
-
let depth = 0;
|
|
136
|
-
depth < 5 && current && typeof current === "object";
|
|
137
|
-
depth++
|
|
138
|
-
) {
|
|
139
|
-
if (DUPLICATE_KEY_CODES.has((current as { code?: unknown }).code))
|
|
140
|
-
return true;
|
|
141
|
-
current = (current as { cause?: unknown }).cause;
|
|
142
|
-
}
|
|
143
|
-
return false;
|
|
144
|
-
}
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { BadRequestException, type PipeTransform } from "@nestjs/common";
|
|
2
|
-
import type { ZodType } from "zod";
|
|
3
|
-
|
|
4
|
-
// Validates a request body against a Zod schema and returns the parsed value
|
|
5
|
-
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
|
|
6
|
-
constructor(private readonly schema: ZodType<T>) {}
|
|
7
|
-
|
|
8
|
-
transform(value: unknown): T {
|
|
9
|
-
// The body is undefined when the request has none
|
|
10
|
-
const result = this.schema.safeParse(value ?? {});
|
|
11
|
-
if (!result.success) {
|
|
12
|
-
throw new BadRequestException(result.error.issues[0].message);
|
|
13
|
-
}
|
|
14
|
-
return result.data;
|
|
15
|
-
}
|
|
16
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
2
|
-
|
|
3
|
-
export const users = pgTable("users", {
|
|
4
|
-
id: uuid("id").primaryKey().defaultRandom(),
|
|
5
|
-
email: text("email").notNull().unique(),
|
|
6
|
-
name: text("name"),
|
|
7
|
-
password: text("password").notNull(),
|
|
8
|
-
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
|
9
|
-
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
10
|
-
.defaultNow()
|
|
11
|
-
.$onUpdate(() => new Date()),
|
|
12
|
-
});
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
2
|
-
|
|
3
|
-
export const users = pgTable("users", {
|
|
4
|
-
id: uuid("id").primaryKey().defaultRandom(),
|
|
5
|
-
email: text("email").notNull().unique(),
|
|
6
|
-
name: text("name"),
|
|
7
|
-
password: text("password").notNull(),
|
|
8
|
-
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
|
9
|
-
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
10
|
-
.defaultNow()
|
|
11
|
-
.$onUpdate(() => new Date()),
|
|
12
|
-
});
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
// Prisma schema file
|
|
2
|
-
// Learn more: https://pris.ly/d/prisma-schema
|
|
3
|
-
|
|
4
|
-
generator client {
|
|
5
|
-
provider = "prisma-client"
|
|
6
|
-
moduleFormat = "cjs"
|
|
7
|
-
output = "../src/generated/prisma"
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
datasource db {
|
|
11
|
-
provider = "postgresql"
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
// Sample User model
|
|
15
|
-
model User {
|
|
16
|
-
id String @id @default(uuid())
|
|
17
|
-
email String @unique
|
|
18
|
-
name String?
|
|
19
|
-
password String
|
|
20
|
-
createdAt DateTime @default(now())
|
|
21
|
-
updatedAt DateTime @updatedAt
|
|
22
|
-
|
|
23
|
-
@@map("users")
|
|
24
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
// Prisma schema file
|
|
2
|
-
// Learn more: https://pris.ly/d/prisma-schema
|
|
3
|
-
|
|
4
|
-
generator client {
|
|
5
|
-
provider = "prisma-client"
|
|
6
|
-
output = "../src/generated/prisma"
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
datasource db {
|
|
10
|
-
provider = "postgresql"
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
// Sample User model
|
|
14
|
-
model User {
|
|
15
|
-
id String @id @default(uuid())
|
|
16
|
-
email String @unique
|
|
17
|
-
name String?
|
|
18
|
-
password String
|
|
19
|
-
createdAt DateTime @default(now())
|
|
20
|
-
updatedAt DateTime @updatedAt
|
|
21
|
-
|
|
22
|
-
@@map("users")
|
|
23
|
-
}
|