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.
Files changed (117) hide show
  1. package/README.md +16 -8
  2. package/dist/args.js +2 -1
  3. package/dist/builder/buildPlanner.js +7 -1
  4. package/dist/builder/configGenerator.js +28 -3
  5. package/dist/builder/index.js +2 -1
  6. package/dist/builder/readmeGenerator.js +2 -2
  7. package/dist/output/nextSteps.js +2 -4
  8. package/dist/prompts/stack.js +3 -5
  9. package/dist/types/index.js +1 -1
  10. package/package.json +1 -1
  11. package/templates/backend/express/src/env.ts.hbs +13 -2
  12. package/templates/backend/express/src/index.ts.hbs +31 -2
  13. package/templates/backend/nestjs/src/app.module.ts.hbs +4 -1
  14. package/templates/backend/nestjs/src/env.ts.hbs +13 -2
  15. package/templates/backend/nestjs/src/health/health.controller.ts.hbs +7 -0
  16. package/templates/backend/nestjs/src/{main.ts → main.ts.hbs} +11 -0
  17. package/templates/frontend/nextjs/app/globals.css +119 -79
  18. package/templates/frontend/nextjs/app/layout.tsx +13 -11
  19. package/templates/frontend/nextjs/app/page.tsx.hbs +63 -43
  20. package/templates/frontend/nextjs/components/ui/alert.tsx +75 -0
  21. package/templates/frontend/nextjs/components/ui/badge.tsx +51 -0
  22. package/templates/frontend/nextjs/components/ui/button.tsx +33 -27
  23. package/templates/frontend/nextjs/components/ui/card.tsx +76 -34
  24. package/templates/frontend/nextjs/components/ui/field.tsx +203 -32
  25. package/templates/frontend/nextjs/components/ui/input.tsx +8 -7
  26. package/templates/frontend/nextjs/components/ui/label.tsx +19 -0
  27. package/templates/frontend/nextjs/components/ui/separator.tsx +24 -0
  28. package/templates/frontend/nextjs/components/ui/skeleton.tsx +13 -0
  29. package/templates/frontend/nextjs/components/ui/spinner.tsx +10 -0
  30. package/templates/frontend/nextjs/components.json +11 -5
  31. package/templates/frontend/nextjs/config.json +4 -3
  32. package/templates/frontend/nextjs/lib/utils.ts +1 -6
  33. package/templates/frontend/tanstack/components.json +12 -5
  34. package/templates/frontend/tanstack/config.json +5 -2
  35. package/templates/frontend/tanstack/src/components/ui/alert.tsx +75 -0
  36. package/templates/frontend/tanstack/src/components/ui/badge.tsx +51 -0
  37. package/templates/frontend/tanstack/src/components/ui/button.tsx +33 -27
  38. package/templates/frontend/tanstack/src/components/ui/card.tsx +76 -34
  39. package/templates/frontend/tanstack/src/components/ui/field.tsx +201 -32
  40. package/templates/frontend/tanstack/src/components/ui/input.tsx +8 -7
  41. package/templates/frontend/tanstack/src/components/ui/label.tsx +17 -0
  42. package/templates/frontend/tanstack/src/components/ui/separator.tsx +24 -0
  43. package/templates/frontend/tanstack/src/components/ui/skeleton.tsx +13 -0
  44. package/templates/frontend/tanstack/src/components/ui/spinner.tsx +10 -0
  45. package/templates/frontend/tanstack/src/lib/utils.ts +1 -6
  46. package/templates/frontend/tanstack/src/routes/index.tsx.hbs +64 -45
  47. package/templates/frontend/tanstack/src/styles/app.css +100 -55
  48. package/templates/modules/agents/AGENTS.md.hbs +29 -21
  49. package/templates/modules/auth/express/config.json +12 -11
  50. package/templates/modules/auth/express/src/lib/auth.ts.hbs +143 -0
  51. package/templates/modules/auth/express/src/lib/email.ts +37 -0
  52. package/templates/modules/auth/express/src/middleware/requireAuth.ts +29 -0
  53. package/templates/modules/auth/express/src/routes/me.ts +13 -0
  54. package/templates/modules/auth/nestjs/config.json +13 -11
  55. package/templates/modules/auth/nestjs/src/auth/auth.module.ts.hbs +64 -14
  56. package/templates/modules/auth/nestjs/src/auth/auth.ts.hbs +152 -0
  57. package/templates/modules/auth/nestjs/src/auth/email.ts +37 -0
  58. package/templates/modules/auth/nestjs/src/me/me.controller.ts +15 -0
  59. package/templates/modules/auth/nextjs/app/dashboard/page.tsx +101 -45
  60. package/templates/modules/auth/nextjs/app/forgot-password/page.tsx +11 -0
  61. package/templates/modules/auth/nextjs/app/reset-password/page.tsx +17 -0
  62. package/templates/modules/auth/nextjs/app/sign-in/page.tsx +23 -17
  63. package/templates/modules/auth/nextjs/app/sign-up/page.tsx +25 -19
  64. package/templates/modules/auth/nextjs/components/auth/change-password-form.tsx +125 -0
  65. package/templates/modules/auth/nextjs/components/auth/email-verification-card.tsx +91 -0
  66. package/templates/modules/auth/nextjs/components/auth/forgot-password-form.tsx +133 -0
  67. package/templates/modules/auth/nextjs/components/auth/reset-password-form.tsx +155 -0
  68. package/templates/modules/auth/nextjs/components/auth/sign-in-form.tsx +46 -32
  69. package/templates/modules/auth/nextjs/components/auth/sign-out-button.tsx +6 -5
  70. package/templates/modules/auth/nextjs/components/auth/sign-up-form.tsx +36 -30
  71. package/templates/modules/auth/nextjs/components/auth/social-buttons.tsx +73 -0
  72. package/templates/modules/auth/nextjs/config.json +8 -2
  73. package/templates/modules/auth/nextjs/lib/auth-schema.ts +27 -2
  74. package/templates/modules/auth/nextjs/lib/auth.ts.hbs +65 -0
  75. package/templates/modules/auth/nextjs/lib/email.ts +43 -0
  76. package/templates/modules/auth/nextjs/lib/session.ts +12 -0
  77. package/templates/modules/auth/tanstack/config.json +9 -3
  78. package/templates/modules/auth/tanstack/src/components/auth/change-password-form.tsx +123 -0
  79. package/templates/modules/auth/tanstack/src/components/auth/email-verification-card.tsx +89 -0
  80. package/templates/modules/auth/tanstack/src/components/auth/forgot-password-form.tsx +131 -0
  81. package/templates/modules/auth/tanstack/src/components/auth/reset-password-form.tsx +149 -0
  82. package/templates/modules/auth/tanstack/src/components/auth/sign-in-form.tsx +44 -35
  83. package/templates/modules/auth/tanstack/src/components/auth/sign-out-button.tsx +7 -6
  84. package/templates/modules/auth/tanstack/src/components/auth/sign-up-form.tsx +37 -34
  85. package/templates/modules/auth/tanstack/src/components/auth/social-buttons.tsx +71 -0
  86. package/templates/modules/auth/tanstack/src/lib/auth-functions.ts +7 -1
  87. package/templates/modules/auth/tanstack/src/lib/auth-schema.ts +27 -2
  88. package/templates/modules/auth/tanstack/src/lib/auth.ts.hbs +61 -1
  89. package/templates/modules/auth/tanstack/src/lib/email.ts +43 -0
  90. package/templates/modules/auth/tanstack/src/routeTree.gen.ts +61 -3
  91. package/templates/modules/auth/tanstack/src/routes/dashboard.tsx +40 -32
  92. package/templates/modules/auth/tanstack/src/routes/forgot-password.tsx +17 -0
  93. package/templates/modules/auth/tanstack/src/routes/reset-password.tsx +25 -0
  94. package/templates/modules/auth/tanstack/src/routes/sign-in.tsx +19 -12
  95. package/templates/modules/auth/tanstack/src/routes/sign-up.tsx +21 -14
  96. package/templates/modules/drizzle/express/src/lib/schema.ts.hbs +90 -0
  97. package/templates/modules/drizzle/nestjs/src/database/schema.ts.hbs +90 -0
  98. package/templates/modules/mongoose/nestjs/config.json +2 -1
  99. package/templates/modules/prisma/express/prisma/schema.prisma.hbs +92 -0
  100. package/templates/modules/prisma/nestjs/prisma/schema.prisma.hbs +91 -0
  101. package/templates/modules/rate-limit/express/src/middleware/rateLimit.ts +0 -8
  102. package/templates/modules/auth/express/src/controllers/authController.ts.hbs +0 -116
  103. package/templates/modules/auth/express/src/lib/jwt.ts.hbs +0 -27
  104. package/templates/modules/auth/express/src/middleware/requireAuth.ts.hbs +0 -25
  105. package/templates/modules/auth/express/src/routes/auth.ts.hbs +0 -27
  106. package/templates/modules/auth/express/src/services/userStore.ts.hbs +0 -117
  107. package/templates/modules/auth/nestjs/src/auth/auth.controller.ts.hbs +0 -61
  108. package/templates/modules/auth/nestjs/src/auth/auth.guard.ts +0 -36
  109. package/templates/modules/auth/nestjs/src/auth/auth.schemas.ts +0 -35
  110. package/templates/modules/auth/nestjs/src/auth/auth.service.ts +0 -70
  111. package/templates/modules/auth/nestjs/src/auth/jwt.ts +0 -27
  112. package/templates/modules/auth/nestjs/src/auth/users.service.ts.hbs +0 -144
  113. package/templates/modules/auth/nestjs/src/auth/zod-validation.pipe.ts +0 -16
  114. package/templates/modules/drizzle/express/src/lib/schema.ts +0 -12
  115. package/templates/modules/drizzle/nestjs/src/database/schema.ts +0 -12
  116. package/templates/modules/prisma/express/prisma/schema.prisma +0 -24
  117. 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": "JWT Auth",
4
- "description": "JWT authentication for NestJS with bcrypt password hashing",
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
- "bcrypt": "^6.0.0",
8
- "jsonwebtoken": "^9.0.3",
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
- "JWT_SECRET": "please-change-me",
18
- "JWT_EXPIRES_IN": "1h"
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 { MongooseModule } from "@nestjs/mongoose";
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 { User, UserSchema } from "../users/user.schema.js";
18
+ import { env } from "../env.js";
8
19
  {{/if}}
9
- import { AuthController } from "./auth.controller.js";
10
- import { AuthService } from "./auth.service.js";
11
- import { UsersService } from "./users.service.js";
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
- @Module({
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
- MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]),
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
- controllers: [AuthController],
20
- providers: [AuthService, UsersService],
21
- })
22
- export class AuthModule {}
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 { buttonVariants } from "@/components/ui/button";
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 { auth } from "@/lib/auth";
16
- import { cn } from "@/lib/utils";
21
+ import { Skeleton } from "@/components/ui/skeleton";
22
+ import { getSession } from "@/lib/session";
17
23
 
18
- export default async function DashboardPage() {
19
- const session = await auth.api.getSession({
20
- headers: await headers(),
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
- <main className="min-h-screen bg-[linear-gradient(180deg,_#f8fafc_0%,_#eef2ff_100%)] px-6 py-16 sm:px-10">
29
- <div className="mx-auto max-w-5xl space-y-8">
30
- <header className="flex flex-col gap-6 rounded-[2rem] border border-white/70 bg-white/85 p-8 shadow-xl shadow-slate-200/60 backdrop-blur md:flex-row md:items-end md:justify-between">
31
- <div className="space-y-3">
32
- <span className="inline-flex items-center gap-2 rounded-full bg-emerald-50 px-3 py-1 text-xs font-medium uppercase tracking-[0.24em] text-emerald-700">
33
- <ShieldCheck className="size-4" />
34
- Authenticated session
35
- </span>
36
- <div className="space-y-1">
37
- <h1 className="text-3xl font-semibold tracking-tight text-slate-950">
38
- Welcome {session.user.name}
39
- </h1>
40
- <p className="text-sm text-slate-600">{session.user.email}</p>
41
- </div>
42
- </div>
43
- <SignOutButton />
44
- </header>
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
- This page verifies the session on the server before rendering any
52
- content.
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="space-y-4 text-sm text-slate-600">
96
+ <CardContent className="flex flex-col gap-4 text-muted-foreground">
56
97
  <p>
57
- The generated proxy only performs an optimistic cookie check. The
58
- authoritative guard happens here with{" "}
59
- <code className="font-mono text-slate-900">
60
- auth.api.getSession({"{"} headers: await headers() {"}"})
61
- </code>
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
+ &lt;Suspense&gt;
109
+ </code>{" "}
110
+ boundary, and the session is looked up once per request.
63
111
  </p>
64
112
  <p>
65
- That keeps route protection aligned with Better Auth&apos;s
66
- Next.js guidance while staying easy to extend.
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="space-y-3 text-sm text-slate-600">
80
- <p>Replace the demo dashboard with your first authenticated page.</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>Add provider-based auth or email verification later if needed.</p>
83
- <Link
84
- href="/"
85
- className={cn(buttonVariants({ variant: "outline" }), "w-fit gap-2")}
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 className="size-4" />
89
- </Link>
90
- </CardContent>
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
+ }