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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { env } from "../env";
|
|
2
|
+
|
|
3
|
+
interface Email {
|
|
4
|
+
to: string;
|
|
5
|
+
subject: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (!env.RESEND_API_KEY && process.env.NODE_ENV === "production") {
|
|
10
|
+
console.warn(
|
|
11
|
+
"RESEND_API_KEY is not set: emails are printed to the log instead of sent"
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Sends through Resend when RESEND_API_KEY is set. Without it the email is
|
|
16
|
+
// printed, so verification and reset links can be copied from the terminal
|
|
17
|
+
export async function sendEmail({ to, subject, text }: Email): Promise<void> {
|
|
18
|
+
if (!env.RESEND_API_KEY) {
|
|
19
|
+
console.log(`\n--- Email to ${to}: ${subject}\n${text}\n---\n`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: {
|
|
26
|
+
Authorization: `Bearer ${env.RESEND_API_KEY}`,
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify({ from: env.EMAIL_FROM, to, subject, text }),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Resend rejected the email (${response.status}): ${await response.text()}`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { fromNodeHeaders } from "better-auth/node";
|
|
2
|
+
import type { NextFunction, Request, Response } from "express";
|
|
3
|
+
|
|
4
|
+
import { auth, type Session } from "../lib/auth";
|
|
5
|
+
|
|
6
|
+
// Accepts a session cookie or an "Authorization: Bearer <token>" header and
|
|
7
|
+
// stores the session in res.locals.session; answers 401 without one
|
|
8
|
+
export function requireAuth(
|
|
9
|
+
req: Request,
|
|
10
|
+
res: Response,
|
|
11
|
+
next: NextFunction
|
|
12
|
+
): void {
|
|
13
|
+
auth.api
|
|
14
|
+
.getSession({ headers: fromNodeHeaders(req.headers) })
|
|
15
|
+
.then((session) => {
|
|
16
|
+
if (!session) {
|
|
17
|
+
res.status(401).json({ error: "Unauthorized" });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
res.locals.session = session;
|
|
21
|
+
next();
|
|
22
|
+
})
|
|
23
|
+
.catch(next);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The session requireAuth stored, for handlers that run after it
|
|
27
|
+
export function getSession(res: Response): Session {
|
|
28
|
+
return res.locals.session as Session;
|
|
29
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Request, Response, Router } from "express";
|
|
2
|
+
|
|
3
|
+
import { getSession, requireAuth } from "../middleware/requireAuth";
|
|
4
|
+
|
|
5
|
+
const router: Router = Router();
|
|
6
|
+
|
|
7
|
+
// Example of a protected route: returns the signed-in user
|
|
8
|
+
router.get("/", requireAuth, (_req: Request, res: Response) => {
|
|
9
|
+
const { user, session } = getSession(res);
|
|
10
|
+
res.status(200).json({ user, session: { expiresAt: session.expiresAt } });
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export default router;
|
|
@@ -1,20 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "auth",
|
|
3
|
-
"name": "
|
|
4
|
-
"description": "
|
|
3
|
+
"name": "Better Auth",
|
|
4
|
+
"description": "Better Auth for NestJS: email and password, email verification, password reset, GitHub and Google sign-in, and bearer tokens",
|
|
5
5
|
"type": "feature",
|
|
6
6
|
"dependencies": {
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"zod": "^4.6.7"
|
|
10
|
-
},
|
|
11
|
-
"devDependencies": {
|
|
12
|
-
"@types/bcrypt": "^6.0.0",
|
|
13
|
-
"@types/jsonwebtoken": "^9.0.10"
|
|
7
|
+
"@thallesp/nestjs-better-auth": "^2.8.0",
|
|
8
|
+
"better-auth": "^1.7.18"
|
|
14
9
|
},
|
|
10
|
+
"devDependencies": {},
|
|
15
11
|
"scripts": {},
|
|
16
12
|
"envVars": {
|
|
17
|
-
"
|
|
18
|
-
"
|
|
13
|
+
"BETTER_AUTH_SECRET": "please-change-me-to-a-random-string",
|
|
14
|
+
"BETTER_AUTH_URL": "http://localhost:3000",
|
|
15
|
+
"GITHUB_CLIENT_ID": "",
|
|
16
|
+
"GITHUB_CLIENT_SECRET": "",
|
|
17
|
+
"GOOGLE_CLIENT_ID": "",
|
|
18
|
+
"GOOGLE_CLIENT_SECRET": "",
|
|
19
|
+
"RESEND_API_KEY": "",
|
|
20
|
+
"EMAIL_FROM": "onboarding@resend.dev"
|
|
19
21
|
}
|
|
20
22
|
}
|
|
@@ -1,22 +1,72 @@
|
|
|
1
|
-
import { Module } from "@nestjs/common";
|
|
2
1
|
{{#if (eq database 'mongoose')}}
|
|
3
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Injectable,
|
|
4
|
+
Module,
|
|
5
|
+
type OnApplicationShutdown,
|
|
6
|
+
} from "@nestjs/common";
|
|
7
|
+
{{/if}}
|
|
8
|
+
import { AuthModule as BetterAuthModule } from "@thallesp/nestjs-better-auth";
|
|
9
|
+
import type { Request, Response } from "express";
|
|
10
|
+
{{#if (eq database 'mongoose')}}
|
|
11
|
+
import { MongoClient } from "mongodb";
|
|
4
12
|
{{/if}}
|
|
5
13
|
|
|
14
|
+
{{#if (eq database 'drizzle')}}
|
|
15
|
+
import { type Database, DRIZZLE } from "../database/database.module.js";
|
|
16
|
+
{{/if}}
|
|
6
17
|
{{#if (eq database 'mongoose')}}
|
|
7
|
-
import {
|
|
18
|
+
import { env } from "../env.js";
|
|
8
19
|
{{/if}}
|
|
9
|
-
|
|
10
|
-
import {
|
|
11
|
-
|
|
20
|
+
{{#if (eq database 'prisma')}}
|
|
21
|
+
import { PrismaService } from "../prisma/prisma.service.js";
|
|
22
|
+
{{/if}}
|
|
23
|
+
import { createAuth } from "./auth.js";
|
|
24
|
+
|
|
25
|
+
{{#if (eq database 'mongoose')}}
|
|
26
|
+
// Better Auth talks to MongoDB through the driver, next to Mongoose
|
|
27
|
+
const mongoClient = new MongoClient(env.MONGODB_URI);
|
|
12
28
|
|
|
13
|
-
@
|
|
29
|
+
@Injectable()
|
|
30
|
+
class MongoClientShutdown implements OnApplicationShutdown {
|
|
31
|
+
onApplicationShutdown(): Promise<void> {
|
|
32
|
+
return mongoClient.close();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Module({ providers: [MongoClientShutdown] })
|
|
37
|
+
class MongoClientModule {}
|
|
38
|
+
|
|
39
|
+
{{/if}}
|
|
40
|
+
// Mounts Better Auth on /api/auth and protects every other route: add
|
|
41
|
+
// @AllowAnonymous() to a controller or route that should stay public
|
|
42
|
+
export const AuthModule = BetterAuthModule.forRootAsync({
|
|
43
|
+
{{#if (eq database 'prisma')}}
|
|
44
|
+
inject: [PrismaService],
|
|
45
|
+
useFactory: (prisma: PrismaService) => ({
|
|
46
|
+
auth: createAuth(prisma),
|
|
47
|
+
{{/if}}
|
|
48
|
+
{{#if (eq database 'drizzle')}}
|
|
49
|
+
inject: [DRIZZLE],
|
|
50
|
+
useFactory: (db: Database) => ({
|
|
51
|
+
auth: createAuth(db),
|
|
52
|
+
{{/if}}
|
|
14
53
|
{{#if (eq database 'mongoose')}}
|
|
15
|
-
imports: [
|
|
16
|
-
|
|
17
|
-
|
|
54
|
+
imports: [MongoClientModule],
|
|
55
|
+
useFactory: () => ({
|
|
56
|
+
auth: createAuth(mongoClient.db()),
|
|
57
|
+
{{/if}}
|
|
58
|
+
{{#if (eq database 'none')}}
|
|
59
|
+
useFactory: () => ({
|
|
60
|
+
auth: createAuth(),
|
|
18
61
|
{{/if}}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
62
|
+
// CORS stays with app.enableCors() in main.ts, which reads CORS_ORIGIN
|
|
63
|
+
disableTrustedOriginsCors: true,
|
|
64
|
+
// Better Auth reads the client IP for its rate limits from this header. It
|
|
65
|
+
// is set from req.ip on every request, so a client cannot pick its own
|
|
66
|
+
// value. Behind a reverse proxy, enable "trust proxy" for the real client IP
|
|
67
|
+
middleware: (req: Request, _res: Response, next: () => void) => {
|
|
68
|
+
req.headers["x-client-ip"] = req.ip;
|
|
69
|
+
next();
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { betterAuth } from "better-auth";
|
|
2
|
+
{{#if (eq database 'none')}}
|
|
3
|
+
import { memoryAdapter } from "better-auth/adapters/memory";
|
|
4
|
+
{{/if}}
|
|
5
|
+
{{#if (eq database 'prisma')}}
|
|
6
|
+
import { prismaAdapter } from "better-auth/adapters/prisma";
|
|
7
|
+
{{/if}}
|
|
8
|
+
{{#if (eq database 'drizzle')}}
|
|
9
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
10
|
+
{{/if}}
|
|
11
|
+
{{#if (eq database 'mongoose')}}
|
|
12
|
+
import { mongodbAdapter } from "better-auth/adapters/mongodb";
|
|
13
|
+
{{/if}}
|
|
14
|
+
import { bearer } from "better-auth/plugins";
|
|
15
|
+
{{#if (eq database 'mongoose')}}
|
|
16
|
+
import type { Db } from "mongodb";
|
|
17
|
+
{{/if}}
|
|
18
|
+
|
|
19
|
+
{{#if (eq database 'drizzle')}}
|
|
20
|
+
import type { Database } from "../database/database.module.js";
|
|
21
|
+
{{/if}}
|
|
22
|
+
import { env } from "../env.js";
|
|
23
|
+
{{#if (eq database 'prisma')}}
|
|
24
|
+
import type { PrismaService } from "../prisma/prisma.service.js";
|
|
25
|
+
{{/if}}
|
|
26
|
+
import { sendEmail } from "./email.js";
|
|
27
|
+
|
|
28
|
+
{{#if (eq database 'none')}}
|
|
29
|
+
// In-memory store for trying the API out: users and sessions are lost on
|
|
30
|
+
// restart, so add a database before going to production
|
|
31
|
+
const memoryDb = { user: [], session: [], account: [], verification: [] };
|
|
32
|
+
|
|
33
|
+
{{/if}}
|
|
34
|
+
// Reports a failed email instead of failing the request: the sign-up or reset
|
|
35
|
+
// already happened, and the user can ask for the email again
|
|
36
|
+
function deliver(email: Parameters<typeof sendEmail>[0]): void {
|
|
37
|
+
sendEmail(email).catch((error: unknown) => {
|
|
38
|
+
console.error(`Could not send "${email.subject}" to ${email.to}:`, error);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Points a verification link at the API itself when the client gave no page
|
|
43
|
+
// to return to, so opening it answers with JSON instead of a redirect to "/"
|
|
44
|
+
function withoutDefaultCallback(url: string): string {
|
|
45
|
+
const link = new URL(url);
|
|
46
|
+
if (link.searchParams.get("callbackURL") === "/") {
|
|
47
|
+
link.searchParams.delete("callbackURL");
|
|
48
|
+
}
|
|
49
|
+
return link.toString();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Builds the Better Auth server; auth.module.ts calls it with the database
|
|
53
|
+
{{#if (eq database 'none')}}
|
|
54
|
+
export function createAuth() {
|
|
55
|
+
{{/if}}
|
|
56
|
+
{{#if (eq database 'prisma')}}
|
|
57
|
+
export function createAuth(prisma: PrismaService) {
|
|
58
|
+
{{/if}}
|
|
59
|
+
{{#if (eq database 'drizzle')}}
|
|
60
|
+
export function createAuth(db: Database) {
|
|
61
|
+
{{/if}}
|
|
62
|
+
{{#if (eq database 'mongoose')}}
|
|
63
|
+
export function createAuth(db: Db) {
|
|
64
|
+
{{/if}}
|
|
65
|
+
return betterAuth({
|
|
66
|
+
{{#if (eq database 'none')}}
|
|
67
|
+
database: memoryAdapter(memoryDb),
|
|
68
|
+
{{/if}}
|
|
69
|
+
{{#if (eq database 'prisma')}}
|
|
70
|
+
database: prismaAdapter(prisma, { provider: "postgresql" }),
|
|
71
|
+
{{/if}}
|
|
72
|
+
{{#if (eq database 'drizzle')}}
|
|
73
|
+
database: drizzleAdapter(db, { provider: "pg" }),
|
|
74
|
+
{{/if}}
|
|
75
|
+
{{#if (eq database 'mongoose')}}
|
|
76
|
+
// Without the client, writes run without transactions, which a standalone
|
|
77
|
+
// MongoDB server (no replica set) does not support
|
|
78
|
+
database: mongodbAdapter(db),
|
|
79
|
+
{{/if}}
|
|
80
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
81
|
+
baseURL: env.BETTER_AUTH_URL,
|
|
82
|
+
// Browser apps on these origins may call the auth routes with cookies
|
|
83
|
+
trustedOrigins: typeof env.CORS_ORIGIN === "string" ? [] : env.CORS_ORIGIN,
|
|
84
|
+
emailAndPassword: {
|
|
85
|
+
enabled: true,
|
|
86
|
+
// Set to true to block sign-in until the email address is verified
|
|
87
|
+
requireEmailVerification: false,
|
|
88
|
+
// A reset signs the user out everywhere
|
|
89
|
+
revokeSessionsOnPasswordReset: true,
|
|
90
|
+
sendResetPassword: ({ user, url, token }) => {
|
|
91
|
+
deliver({
|
|
92
|
+
to: user.email,
|
|
93
|
+
subject: "Reset your password",
|
|
94
|
+
text: `Open this link to choose a new password:\n${url}\n\nOr send this token to POST /api/auth/reset-password: ${token}`,
|
|
95
|
+
});
|
|
96
|
+
return Promise.resolve();
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
emailVerification: {
|
|
100
|
+
sendOnSignUp: true,
|
|
101
|
+
autoSignInAfterVerification: true,
|
|
102
|
+
sendVerificationEmail: ({ user, url }) => {
|
|
103
|
+
deliver({
|
|
104
|
+
to: user.email,
|
|
105
|
+
subject: "Verify your email address",
|
|
106
|
+
text: `Open this link to verify your email address:\n${withoutDefaultCallback(url)}`,
|
|
107
|
+
});
|
|
108
|
+
return Promise.resolve();
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
// Each provider is turned on once its client ID and secret are set
|
|
112
|
+
socialProviders: {
|
|
113
|
+
...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
|
|
114
|
+
? {
|
|
115
|
+
github: {
|
|
116
|
+
clientId: env.GITHUB_CLIENT_ID,
|
|
117
|
+
clientSecret: env.GITHUB_CLIENT_SECRET,
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
: {}),
|
|
121
|
+
...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
|
|
122
|
+
? {
|
|
123
|
+
google: {
|
|
124
|
+
clientId: env.GOOGLE_CLIENT_ID,
|
|
125
|
+
clientSecret: env.GOOGLE_CLIENT_SECRET,
|
|
126
|
+
},
|
|
127
|
+
}
|
|
128
|
+
: {}),
|
|
129
|
+
},
|
|
130
|
+
// Clients without cookies send "Authorization: Bearer <token>"; the token
|
|
131
|
+
// comes back in the set-auth-token header of sign-in and sign-up
|
|
132
|
+
plugins: [bearer()],
|
|
133
|
+
{{#if useRateLimit}}
|
|
134
|
+
// Sign-up, sign-in, and password reset: 10 attempts per client IP in 15
|
|
135
|
+
// minutes, against password guessing
|
|
136
|
+
rateLimit: {
|
|
137
|
+
enabled: true,
|
|
138
|
+
window: 15 * 60,
|
|
139
|
+
max: 100,
|
|
140
|
+
customRules: {
|
|
141
|
+
"/sign-in/*": { window: 15 * 60, max: 10 },
|
|
142
|
+
"/sign-up/*": { window: 15 * 60, max: 10 },
|
|
143
|
+
"/request-password-reset": { window: 15 * 60, max: 10 },
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
{{/if}}
|
|
147
|
+
advanced: {
|
|
148
|
+
// Set from req.ip on every request (see auth.module.ts)
|
|
149
|
+
ipAddress: { ipAddressHeaders: ["x-client-ip"] },
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { env } from "../env.js";
|
|
2
|
+
|
|
3
|
+
interface Email {
|
|
4
|
+
to: string;
|
|
5
|
+
subject: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (!env.RESEND_API_KEY && process.env.NODE_ENV === "production") {
|
|
10
|
+
console.warn(
|
|
11
|
+
"RESEND_API_KEY is not set: emails are printed to the log instead of sent"
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Sends through Resend when RESEND_API_KEY is set. Without it the email is
|
|
16
|
+
// printed, so verification and reset links can be copied from the terminal
|
|
17
|
+
export async function sendEmail({ to, subject, text }: Email): Promise<void> {
|
|
18
|
+
if (!env.RESEND_API_KEY) {
|
|
19
|
+
console.log(`\n--- Email to ${to}: ${subject}\n${text}\n---\n`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const response = await fetch("https://api.resend.com/emails", {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: {
|
|
26
|
+
Authorization: `Bearer ${env.RESEND_API_KEY}`,
|
|
27
|
+
"Content-Type": "application/json",
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify({ from: env.EMAIL_FROM, to, subject, text }),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Resend rejected the email (${response.status}): ${await response.text()}`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Controller, Get } from "@nestjs/common";
|
|
2
|
+
import { Session, type UserSession } from "@thallesp/nestjs-better-auth";
|
|
3
|
+
|
|
4
|
+
// Example of a protected route: the global auth guard answers 401 without a
|
|
5
|
+
// session cookie or an "Authorization: Bearer <token>" header
|
|
6
|
+
@Controller("api/me")
|
|
7
|
+
export class MeController {
|
|
8
|
+
@Get()
|
|
9
|
+
me(@Session() { user, session }: UserSession): {
|
|
10
|
+
user: UserSession["user"];
|
|
11
|
+
session: { expiresAt: Date };
|
|
12
|
+
} {
|
|
13
|
+
return { user, session: { expiresAt: session.expiresAt } };
|
|
14
|
+
}
|
|
15
|
+
}
|
|
@@ -1,69 +1,117 @@
|
|
|
1
|
+
import { Suspense } from "react";
|
|
2
|
+
|
|
1
3
|
import Link from "next/link";
|
|
2
|
-
import { headers } from "next/headers";
|
|
3
4
|
import { redirect } from "next/navigation";
|
|
5
|
+
|
|
4
6
|
import { ArrowRight, ShieldCheck } from "lucide-react";
|
|
5
7
|
|
|
8
|
+
import { ChangePasswordForm } from "@/components/auth/change-password-form";
|
|
9
|
+
import { EmailVerificationCard } from "@/components/auth/email-verification-card";
|
|
6
10
|
import { SignOutButton } from "@/components/auth/sign-out-button";
|
|
7
|
-
import {
|
|
11
|
+
import { Badge } from "@/components/ui/badge";
|
|
12
|
+
import { Button } from "@/components/ui/button";
|
|
8
13
|
import {
|
|
9
14
|
Card,
|
|
10
15
|
CardContent,
|
|
11
16
|
CardDescription,
|
|
17
|
+
CardFooter,
|
|
12
18
|
CardHeader,
|
|
13
19
|
CardTitle,
|
|
14
20
|
} from "@/components/ui/card";
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
21
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
22
|
+
import { getSession } from "@/lib/session";
|
|
17
23
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
// The parts that need the user wait for the session in their own <Suspense>
|
|
25
|
+
// boundary, so the rest of the page is sent without waiting for it
|
|
26
|
+
async function DashboardHeader() {
|
|
27
|
+
const session = await getSession();
|
|
22
28
|
|
|
29
|
+
// proxy.ts already sent visitors without a session cookie to /sign-in; this
|
|
30
|
+
// catches an expired or revoked session
|
|
23
31
|
if (!session) {
|
|
24
32
|
redirect("/sign-in");
|
|
25
33
|
}
|
|
26
34
|
|
|
27
35
|
return (
|
|
28
|
-
<
|
|
29
|
-
<div className="
|
|
30
|
-
<
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
<header className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
|
37
|
+
<div className="flex flex-col gap-3">
|
|
38
|
+
<Badge variant="secondary">
|
|
39
|
+
<ShieldCheck data-icon="inline-start" />
|
|
40
|
+
Authenticated session
|
|
41
|
+
</Badge>
|
|
42
|
+
<div className="flex flex-col gap-1">
|
|
43
|
+
<h1 className="text-3xl font-semibold tracking-tight">
|
|
44
|
+
Welcome {session.user.name}
|
|
45
|
+
</h1>
|
|
46
|
+
<p className="text-sm text-muted-foreground">{session.user.email}</p>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
<SignOutButton />
|
|
50
|
+
</header>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function DashboardHeaderSkeleton() {
|
|
55
|
+
return (
|
|
56
|
+
<div className="flex flex-col gap-3" aria-busy="true">
|
|
57
|
+
<Skeleton className="h-5 w-44" />
|
|
58
|
+
<Skeleton className="h-9 w-64" />
|
|
59
|
+
<Skeleton className="h-4 w-40" />
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function EmailVerification() {
|
|
65
|
+
const session = await getSession();
|
|
66
|
+
|
|
67
|
+
return session ? (
|
|
68
|
+
<EmailVerificationCard
|
|
69
|
+
email={session.user.email}
|
|
70
|
+
emailVerified={session.user.emailVerified}
|
|
71
|
+
/>
|
|
72
|
+
) : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export default function DashboardPage() {
|
|
76
|
+
return (
|
|
77
|
+
<main className="min-h-svh bg-muted px-6 py-16 sm:px-10">
|
|
78
|
+
<div className="mx-auto flex max-w-5xl flex-col gap-8">
|
|
79
|
+
<Suspense fallback={<DashboardHeaderSkeleton />}>
|
|
80
|
+
<DashboardHeader />
|
|
81
|
+
</Suspense>
|
|
45
82
|
|
|
46
83
|
<section className="grid gap-5 md:grid-cols-2">
|
|
84
|
+
<Suspense fallback={<Skeleton className="h-40 rounded-xl" />}>
|
|
85
|
+
<EmailVerification />
|
|
86
|
+
</Suspense>
|
|
87
|
+
<ChangePasswordForm />
|
|
47
88
|
<Card>
|
|
48
89
|
<CardHeader>
|
|
49
90
|
<CardTitle>Protected route example</CardTitle>
|
|
50
91
|
<CardDescription>
|
|
51
|
-
|
|
52
|
-
|
|
92
|
+
The page is sent right away; the parts that need the user stream
|
|
93
|
+
in once the server has checked the session.
|
|
53
94
|
</CardDescription>
|
|
54
95
|
</CardHeader>
|
|
55
|
-
<CardContent className="
|
|
96
|
+
<CardContent className="flex flex-col gap-4 text-muted-foreground">
|
|
56
97
|
<p>
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
98
|
+
<code className="font-mono text-foreground">proxy.ts</code>{" "}
|
|
99
|
+
sends visitors without a session cookie to the sign-in page.
|
|
100
|
+
Each part that needs the user calls{" "}
|
|
101
|
+
<code className="font-mono text-foreground">getSession()</code>{" "}
|
|
102
|
+
from{" "}
|
|
103
|
+
<code className="font-mono text-foreground">
|
|
104
|
+
lib/session.ts
|
|
105
|
+
</code>{" "}
|
|
106
|
+
inside its own{" "}
|
|
107
|
+
<code className="font-mono text-foreground">
|
|
108
|
+
<Suspense>
|
|
109
|
+
</code>{" "}
|
|
110
|
+
boundary, and the session is looked up once per request.
|
|
63
111
|
</p>
|
|
64
112
|
<p>
|
|
65
|
-
|
|
66
|
-
|
|
113
|
+
Check the session on the server like this in every protected
|
|
114
|
+
page, route handler, and server action.
|
|
67
115
|
</p>
|
|
68
116
|
</CardContent>
|
|
69
117
|
</Card>
|
|
@@ -76,18 +124,26 @@ export default async function DashboardPage() {
|
|
|
76
124
|
product.
|
|
77
125
|
</CardDescription>
|
|
78
126
|
</CardHeader>
|
|
79
|
-
<CardContent className="
|
|
80
|
-
<p>
|
|
127
|
+
<CardContent className="flex flex-col gap-3 text-muted-foreground">
|
|
128
|
+
<p>
|
|
129
|
+
Replace the demo dashboard with your first authenticated page.
|
|
130
|
+
</p>
|
|
81
131
|
<p>Extend the schema with profile or onboarding fields.</p>
|
|
82
|
-
<p>
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
132
|
+
<p>
|
|
133
|
+
Add a GitHub or Google client ID and secret to .env to turn on
|
|
134
|
+
social sign-in, and RESEND_API_KEY to send real emails.
|
|
135
|
+
</p>
|
|
136
|
+
</CardContent>
|
|
137
|
+
<CardFooter>
|
|
138
|
+
<Button
|
|
139
|
+
variant="outline"
|
|
140
|
+
nativeButton={false}
|
|
141
|
+
render={<Link href="/" />}
|
|
86
142
|
>
|
|
87
143
|
Back to home
|
|
88
|
-
<ArrowRight
|
|
89
|
-
</
|
|
90
|
-
</
|
|
144
|
+
<ArrowRight data-icon="inline-end" />
|
|
145
|
+
</Button>
|
|
146
|
+
</CardFooter>
|
|
91
147
|
</Card>
|
|
92
148
|
</section>
|
|
93
149
|
</div>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { ForgotPasswordForm } from "@/components/auth/forgot-password-form";
|
|
2
|
+
|
|
3
|
+
export default function ForgotPasswordPage() {
|
|
4
|
+
return (
|
|
5
|
+
<main className="flex min-h-svh items-center justify-center bg-muted p-6 md:p-10">
|
|
6
|
+
<div className="w-full max-w-md">
|
|
7
|
+
<ForgotPasswordForm />
|
|
8
|
+
</div>
|
|
9
|
+
</main>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Suspense } from "react";
|
|
2
|
+
|
|
3
|
+
import { ResetPasswordForm } from "@/components/auth/reset-password-form";
|
|
4
|
+
|
|
5
|
+
// A static page: the form reads the token from the URL in the browser, inside
|
|
6
|
+
// <Suspense> as useSearchParams() requires
|
|
7
|
+
export default function ResetPasswordPage() {
|
|
8
|
+
return (
|
|
9
|
+
<main className="flex min-h-svh items-center justify-center bg-muted p-6 md:p-10">
|
|
10
|
+
<div className="w-full max-w-md">
|
|
11
|
+
<Suspense>
|
|
12
|
+
<ResetPasswordForm />
|
|
13
|
+
</Suspense>
|
|
14
|
+
</div>
|
|
15
|
+
</main>
|
|
16
|
+
);
|
|
17
|
+
}
|