better-ts-stack 2.1.1 → 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 (174) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +16 -8
  3. package/dist/args.js +7 -0
  4. package/dist/builder/buildPlanner.js +7 -1
  5. package/dist/builder/configGenerator.js +28 -3
  6. package/dist/builder/index.js +2 -1
  7. package/dist/builder/moduleSelector.js +6 -1
  8. package/dist/builder/readmeGenerator.js +8 -2
  9. package/dist/builder/templateContext.js +1 -0
  10. package/dist/output/nextSteps.js +2 -4
  11. package/dist/prompts/stack.js +6 -1
  12. package/dist/types/index.js +2 -0
  13. package/dist/ui/messages.js +2 -0
  14. package/package.json +71 -71
  15. package/templates/backend/express/.prettierignore +52 -52
  16. package/templates/backend/express/.prettierrc +16 -16
  17. package/templates/backend/express/gitignore +3 -1
  18. package/templates/backend/express/src/env.ts.hbs +13 -2
  19. package/templates/backend/express/src/index.ts.hbs +31 -2
  20. package/templates/backend/express/tsconfig.eslint.json +9 -9
  21. package/templates/backend/nestjs/.prettierignore +52 -52
  22. package/templates/backend/nestjs/gitignore +3 -1
  23. package/templates/backend/nestjs/src/app.module.ts.hbs +4 -1
  24. package/templates/backend/nestjs/src/env.ts.hbs +13 -2
  25. package/templates/backend/nestjs/src/health/health.controller.ts.hbs +7 -0
  26. package/templates/backend/nestjs/src/{main.ts → main.ts.hbs} +11 -0
  27. package/templates/backend/nestjs/tsconfig.eslint.json +9 -9
  28. package/templates/frontend/nextjs/app/globals.css +129 -89
  29. package/templates/frontend/nextjs/app/layout.tsx +36 -34
  30. package/templates/frontend/nextjs/app/page.tsx.hbs +63 -43
  31. package/templates/frontend/nextjs/components/ui/alert.tsx +75 -0
  32. package/templates/frontend/nextjs/components/ui/badge.tsx +51 -0
  33. package/templates/frontend/nextjs/components/ui/button.tsx +57 -51
  34. package/templates/frontend/nextjs/components/ui/card.tsx +102 -60
  35. package/templates/frontend/nextjs/components/ui/field.tsx +238 -67
  36. package/templates/frontend/nextjs/components/ui/input.tsx +19 -18
  37. package/templates/frontend/nextjs/components/ui/label.tsx +19 -0
  38. package/templates/frontend/nextjs/components/ui/separator.tsx +24 -0
  39. package/templates/frontend/nextjs/components/ui/skeleton.tsx +13 -0
  40. package/templates/frontend/nextjs/components/ui/spinner.tsx +10 -0
  41. package/templates/frontend/nextjs/components.json +11 -5
  42. package/templates/frontend/nextjs/config.json +36 -35
  43. package/templates/frontend/nextjs/eslint.config.mjs +11 -11
  44. package/templates/frontend/nextjs/lib/utils.ts +1 -6
  45. package/templates/frontend/nextjs/next.config.ts.hbs +12 -12
  46. package/templates/frontend/nextjs/postcss.config.mjs +7 -7
  47. package/templates/frontend/nextjs/public/file.svg +1 -1
  48. package/templates/frontend/nextjs/public/globe.svg +1 -1
  49. package/templates/frontend/nextjs/public/window.svg +1 -1
  50. package/templates/frontend/tanstack/components.json +25 -18
  51. package/templates/frontend/tanstack/config.json +5 -2
  52. package/templates/frontend/tanstack/eslint.config.mjs +23 -23
  53. package/templates/frontend/tanstack/gitignore +39 -39
  54. package/templates/frontend/tanstack/public/favicon.svg +7 -7
  55. package/templates/frontend/tanstack/src/components/ui/alert.tsx +75 -0
  56. package/templates/frontend/tanstack/src/components/ui/badge.tsx +51 -0
  57. package/templates/frontend/tanstack/src/components/ui/button.tsx +57 -51
  58. package/templates/frontend/tanstack/src/components/ui/card.tsx +102 -60
  59. package/templates/frontend/tanstack/src/components/ui/field.tsx +236 -67
  60. package/templates/frontend/tanstack/src/components/ui/input.tsx +19 -18
  61. package/templates/frontend/tanstack/src/components/ui/label.tsx +17 -0
  62. package/templates/frontend/tanstack/src/components/ui/separator.tsx +24 -0
  63. package/templates/frontend/tanstack/src/components/ui/skeleton.tsx +13 -0
  64. package/templates/frontend/tanstack/src/components/ui/spinner.tsx +10 -0
  65. package/templates/frontend/tanstack/src/lib/utils.ts +1 -6
  66. package/templates/frontend/tanstack/src/router.tsx +31 -31
  67. package/templates/frontend/tanstack/src/routes/__root.tsx +69 -69
  68. package/templates/frontend/tanstack/src/routes/index.tsx.hbs +64 -45
  69. package/templates/frontend/tanstack/src/styles/app.css +100 -55
  70. package/templates/frontend/tanstack/tsconfig.json +22 -22
  71. package/templates/modules/agents/AGENTS.md.hbs +245 -0
  72. package/templates/modules/agents/CLAUDE.md +1 -0
  73. package/templates/modules/agents/config.json +10 -0
  74. package/templates/modules/auth/express/config.json +12 -11
  75. package/templates/modules/auth/express/src/lib/auth.ts.hbs +143 -0
  76. package/templates/modules/auth/express/src/lib/email.ts +37 -0
  77. package/templates/modules/auth/express/src/middleware/requireAuth.ts +29 -0
  78. package/templates/modules/auth/express/src/routes/me.ts +13 -0
  79. package/templates/modules/auth/nestjs/config.json +13 -11
  80. package/templates/modules/auth/nestjs/src/auth/auth.module.ts.hbs +64 -14
  81. package/templates/modules/auth/nestjs/src/auth/auth.ts.hbs +152 -0
  82. package/templates/modules/auth/nestjs/src/auth/email.ts +37 -0
  83. package/templates/modules/auth/nestjs/src/me/me.controller.ts +15 -0
  84. package/templates/modules/auth/nextjs/app/api/auth/[...all]/route.ts +4 -4
  85. package/templates/modules/auth/nextjs/app/dashboard/page.tsx +101 -45
  86. package/templates/modules/auth/nextjs/app/forgot-password/page.tsx +11 -0
  87. package/templates/modules/auth/nextjs/app/reset-password/page.tsx +17 -0
  88. package/templates/modules/auth/nextjs/app/sign-in/page.tsx +23 -17
  89. package/templates/modules/auth/nextjs/app/sign-up/page.tsx +25 -19
  90. package/templates/modules/auth/nextjs/components/auth/change-password-form.tsx +125 -0
  91. package/templates/modules/auth/nextjs/components/auth/email-verification-card.tsx +91 -0
  92. package/templates/modules/auth/nextjs/components/auth/forgot-password-form.tsx +133 -0
  93. package/templates/modules/auth/nextjs/components/auth/reset-password-form.tsx +155 -0
  94. package/templates/modules/auth/nextjs/components/auth/sign-in-form.tsx +46 -32
  95. package/templates/modules/auth/nextjs/components/auth/sign-out-button.tsx +6 -5
  96. package/templates/modules/auth/nextjs/components/auth/sign-up-form.tsx +36 -30
  97. package/templates/modules/auth/nextjs/components/auth/social-buttons.tsx +73 -0
  98. package/templates/modules/auth/nextjs/config.json +24 -18
  99. package/templates/modules/auth/nextjs/lib/auth-client.ts +3 -3
  100. package/templates/modules/auth/nextjs/lib/auth-schema.ts +27 -2
  101. package/templates/modules/auth/nextjs/lib/auth.ts.hbs +65 -0
  102. package/templates/modules/auth/nextjs/lib/email.ts +43 -0
  103. package/templates/modules/auth/nextjs/lib/session.ts +12 -0
  104. package/templates/modules/auth/tanstack/config.json +25 -19
  105. package/templates/modules/auth/tanstack/src/components/auth/change-password-form.tsx +123 -0
  106. package/templates/modules/auth/tanstack/src/components/auth/email-verification-card.tsx +89 -0
  107. package/templates/modules/auth/tanstack/src/components/auth/forgot-password-form.tsx +131 -0
  108. package/templates/modules/auth/tanstack/src/components/auth/reset-password-form.tsx +149 -0
  109. package/templates/modules/auth/tanstack/src/components/auth/sign-in-form.tsx +44 -35
  110. package/templates/modules/auth/tanstack/src/components/auth/sign-out-button.tsx +7 -6
  111. package/templates/modules/auth/tanstack/src/components/auth/sign-up-form.tsx +37 -34
  112. package/templates/modules/auth/tanstack/src/components/auth/social-buttons.tsx +71 -0
  113. package/templates/modules/auth/tanstack/src/lib/auth-client.ts +2 -2
  114. package/templates/modules/auth/tanstack/src/lib/auth-functions.ts +14 -8
  115. package/templates/modules/auth/tanstack/src/lib/auth-schema.ts +27 -2
  116. package/templates/modules/auth/tanstack/src/lib/auth.ts.hbs +98 -38
  117. package/templates/modules/auth/tanstack/src/lib/email.ts +43 -0
  118. package/templates/modules/auth/tanstack/src/routeTree.gen.ts +61 -3
  119. package/templates/modules/auth/tanstack/src/routes/api/auth/$.ts +15 -15
  120. package/templates/modules/auth/tanstack/src/routes/dashboard.tsx +119 -111
  121. package/templates/modules/auth/tanstack/src/routes/forgot-password.tsx +17 -0
  122. package/templates/modules/auth/tanstack/src/routes/reset-password.tsx +25 -0
  123. package/templates/modules/auth/tanstack/src/routes/sign-in.tsx +44 -37
  124. package/templates/modules/auth/tanstack/src/routes/sign-up.tsx +44 -37
  125. package/templates/modules/docker/.dockerignore +2 -0
  126. package/templates/modules/docker/config.json +15 -15
  127. package/templates/modules/drizzle/express/config.json +26 -26
  128. package/templates/modules/drizzle/express/src/lib/schema.ts.hbs +90 -0
  129. package/templates/modules/drizzle/nestjs/config.json +26 -26
  130. package/templates/modules/drizzle/nestjs/src/database/schema.ts.hbs +90 -0
  131. package/templates/modules/drizzle/nextjs/config.json +26 -26
  132. package/templates/modules/drizzle/nextjs/drizzle.config.ts +13 -13
  133. package/templates/modules/drizzle/tanstack/config.json +26 -26
  134. package/templates/modules/drizzle/tanstack/drizzle.config.ts +12 -12
  135. package/templates/modules/drizzle/tanstack/src/lib/db.ts +13 -13
  136. package/templates/modules/mongoose/express/config.json +15 -15
  137. package/templates/modules/mongoose/express/src/lib/db.ts +43 -43
  138. package/templates/modules/mongoose/express/src/models/User.ts +48 -48
  139. package/templates/modules/mongoose/nestjs/config.json +16 -15
  140. package/templates/modules/mongoose/nextjs/config.json +15 -15
  141. package/templates/modules/mongoose/nextjs/models/User.ts +48 -48
  142. package/templates/modules/mongoose/tanstack/config.json +16 -16
  143. package/templates/modules/mongoose/tanstack/src/models/User.ts +48 -48
  144. package/templates/modules/prisma/express/config.json +27 -27
  145. package/templates/modules/prisma/express/prisma/schema.prisma.hbs +92 -0
  146. package/templates/modules/prisma/nestjs/config.json +27 -27
  147. package/templates/modules/prisma/nestjs/prisma/schema.prisma.hbs +91 -0
  148. package/templates/modules/prisma/nextjs/config.json +28 -28
  149. package/templates/modules/prisma/nextjs/lib/prisma.ts +18 -18
  150. package/templates/modules/prisma/nextjs/prisma.config.ts +14 -14
  151. package/templates/modules/prisma/tanstack/config.json +28 -28
  152. package/templates/modules/prisma/tanstack/prisma/schema.prisma.hbs +91 -91
  153. package/templates/modules/prisma/tanstack/prisma.config.ts +14 -14
  154. package/templates/modules/prisma/tanstack/src/lib/prisma.ts +17 -17
  155. package/templates/modules/rate-limit/express/src/middleware/rateLimit.ts +0 -8
  156. package/templates/modules/vscode/.vscode/extensions.json.hbs +29 -0
  157. package/templates/modules/vscode/.vscode/settings.json.hbs +22 -0
  158. package/templates/modules/vscode/config.json +10 -0
  159. package/templates/modules/auth/express/src/controllers/authController.ts.hbs +0 -116
  160. package/templates/modules/auth/express/src/lib/jwt.ts.hbs +0 -27
  161. package/templates/modules/auth/express/src/middleware/requireAuth.ts.hbs +0 -25
  162. package/templates/modules/auth/express/src/routes/auth.ts.hbs +0 -27
  163. package/templates/modules/auth/express/src/services/userStore.ts.hbs +0 -117
  164. package/templates/modules/auth/nestjs/src/auth/auth.controller.ts.hbs +0 -61
  165. package/templates/modules/auth/nestjs/src/auth/auth.guard.ts +0 -36
  166. package/templates/modules/auth/nestjs/src/auth/auth.schemas.ts +0 -35
  167. package/templates/modules/auth/nestjs/src/auth/auth.service.ts +0 -70
  168. package/templates/modules/auth/nestjs/src/auth/jwt.ts +0 -27
  169. package/templates/modules/auth/nestjs/src/auth/users.service.ts.hbs +0 -144
  170. package/templates/modules/auth/nestjs/src/auth/zod-validation.pipe.ts +0 -16
  171. package/templates/modules/drizzle/express/src/lib/schema.ts +0 -12
  172. package/templates/modules/drizzle/nestjs/src/database/schema.ts +0 -12
  173. package/templates/modules/prisma/express/prisma/schema.prisma +0 -24
  174. package/templates/modules/prisma/nestjs/prisma/schema.prisma +0 -23
@@ -1,18 +1,24 @@
1
- {
2
- "id": "auth",
3
- "name": "Better Auth",
4
- "description": "Better Auth for Next.js with optional Prisma or Drizzle adapter",
5
- "type": "feature",
6
- "dependencies": {
7
- "@hookform/resolvers": "^5.9.2",
8
- "better-auth": "^1.7.14",
9
- "react-hook-form": "^7.91.0",
10
- "zod": "^4.6.7"
11
- },
12
- "devDependencies": {},
13
- "scripts": {},
14
- "envVars": {
15
- "BETTER_AUTH_SECRET": "please-change-me-to-a-random-string",
16
- "BETTER_AUTH_URL": "http://localhost:3000"
17
- }
18
- }
1
+ {
2
+ "id": "auth",
3
+ "name": "Better Auth",
4
+ "description": "Better Auth with email verification, password reset, and GitHub and Google sign-in for Next.js with optional Prisma or Drizzle adapter",
5
+ "type": "feature",
6
+ "dependencies": {
7
+ "@hookform/resolvers": "^5.9.2",
8
+ "better-auth": "^1.7.14",
9
+ "react-hook-form": "^7.91.0",
10
+ "zod": "^4.6.7"
11
+ },
12
+ "devDependencies": {},
13
+ "scripts": {},
14
+ "envVars": {
15
+ "BETTER_AUTH_SECRET": "please-change-me-to-a-random-string",
16
+ "BETTER_AUTH_URL": "http://localhost:3000",
17
+ "GITHUB_CLIENT_ID": "",
18
+ "GITHUB_CLIENT_SECRET": "",
19
+ "GOOGLE_CLIENT_ID": "",
20
+ "GOOGLE_CLIENT_SECRET": "",
21
+ "RESEND_API_KEY": "",
22
+ "EMAIL_FROM": "onboarding@resend.dev"
23
+ }
24
+ }
@@ -1,3 +1,3 @@
1
- import { createAuthClient } from "better-auth/react";
2
-
3
- export const authClient = createAuthClient();
1
+ import { createAuthClient } from "better-auth/react";
2
+
3
+ export const authClient = createAuthClient();
@@ -1,8 +1,13 @@
1
1
  import { z } from "zod";
2
2
 
3
+ const emailSchema = z.email("Enter a valid email address.");
4
+ const passwordSchema = z
5
+ .string()
6
+ .min(8, "Password must be at least 8 characters.");
7
+
3
8
  export const signInSchema = z.object({
4
- email: z.email("Enter a valid email address."),
5
- password: z.string().min(8, "Password must be at least 8 characters."),
9
+ email: emailSchema,
10
+ password: passwordSchema,
6
11
  });
7
12
 
8
13
  export const signUpSchema = signInSchema.extend({
@@ -13,8 +18,28 @@ export const signUpSchema = signInSchema.extend({
13
18
  .max(50, "Name must be 50 characters or fewer."),
14
19
  });
15
20
 
21
+ export const forgotPasswordSchema = z.object({ email: emailSchema });
22
+
23
+ export const resetPasswordSchema = z
24
+ .object({ password: passwordSchema, confirmPassword: z.string() })
25
+ .refine((values) => values.password === values.confirmPassword, {
26
+ message: "Passwords do not match.",
27
+ path: ["confirmPassword"],
28
+ });
29
+
30
+ export const changePasswordSchema = z.object({
31
+ currentPassword: z.string().min(1, "Enter your current password."),
32
+ newPassword: passwordSchema,
33
+ });
34
+
16
35
  export type SignInValues = z.infer<typeof signInSchema>;
17
36
  export type SignUpValues = z.infer<typeof signUpSchema>;
37
+ export type ForgotPasswordValues = z.infer<typeof forgotPasswordSchema>;
38
+ export type ResetPasswordValues = z.infer<typeof resetPasswordSchema>;
39
+ export type ChangePasswordValues = z.infer<typeof changePasswordSchema>;
40
+
41
+ // Sign-in providers the auth server can turn on (see auth.ts)
42
+ export type SocialProvider = "github" | "google";
18
43
 
19
44
  export function getAuthErrorMessage(error: unknown) {
20
45
  if (!error) {
@@ -1,5 +1,9 @@
1
+ import { after } from "next/server";
1
2
  import { betterAuth } from "better-auth";
2
3
  import { nextCookies } from "better-auth/next-js";
4
+
5
+ import type { SocialProvider } from "./auth-schema";
6
+ import { sendEmail } from "./email";
3
7
  {{#if (eq database 'prisma')}}
4
8
  import { prismaAdapter } from "better-auth/adapters/prisma";
5
9
  import client from "./prisma";
@@ -16,6 +20,18 @@ const client = new MongoClient(process.env.MONGODB_URI!);
16
20
  const db = client.db();
17
21
  {{/if}}
18
22
 
23
+ // Sends the email after the response, so the request does not wait for it.
24
+ // after() keeps a serverless function (such as on Vercel) running until the
25
+ // email is sent. A failed email is logged instead of failing the request: the
26
+ // sign-up or reset already happened, and the user can ask for the email again
27
+ function deliver(email: Parameters<typeof sendEmail>[0]): void {
28
+ after(() =>
29
+ sendEmail(email).catch((error: unknown) => {
30
+ console.error(`Could not send "${email.subject}" to ${email.to}:`, error);
31
+ })
32
+ );
33
+ }
34
+
19
35
  export const auth = betterAuth({
20
36
  {{#if (eq database 'prisma')}}
21
37
  database: prismaAdapter(client, { provider: "postgresql" }),
@@ -30,8 +46,57 @@ export const auth = betterAuth({
30
46
  {{/if}}
31
47
  emailAndPassword: {
32
48
  enabled: true,
49
+ // Set to true to block sign-in until the email address is verified
50
+ requireEmailVerification: false,
51
+ // A reset signs the user out everywhere
52
+ revokeSessionsOnPasswordReset: true,
53
+ sendResetPassword: async ({ user, url }) => {
54
+ deliver({
55
+ to: user.email,
56
+ subject: "Reset your password",
57
+ text: `Open this link to choose a new password:
58
+ ${url}`,
59
+ });
60
+ },
61
+ },
62
+ emailVerification: {
63
+ sendOnSignUp: true,
64
+ autoSignInAfterVerification: true,
65
+ sendVerificationEmail: async ({ user, url }) => {
66
+ deliver({
67
+ to: user.email,
68
+ subject: "Verify your email address",
69
+ text: `Open this link to verify your email address:
70
+ ${url}`,
71
+ });
72
+ },
73
+ },
74
+ // Each provider is turned on once its client ID and secret are set
75
+ socialProviders: {
76
+ ...(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET
77
+ ? {
78
+ github: {
79
+ clientId: process.env.GITHUB_CLIENT_ID,
80
+ clientSecret: process.env.GITHUB_CLIENT_SECRET,
81
+ },
82
+ }
83
+ : {}),
84
+ ...(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET
85
+ ? {
86
+ google: {
87
+ clientId: process.env.GOOGLE_CLIENT_ID,
88
+ clientSecret: process.env.GOOGLE_CLIENT_SECRET,
89
+ },
90
+ }
91
+ : {}),
33
92
  },
34
93
  plugins: [nextCookies()],
35
94
  secret: process.env.BETTER_AUTH_SECRET,
36
95
  baseURL: process.env.BETTER_AUTH_URL,
37
96
  });
97
+
98
+ // The providers turned on above; the sign-in and sign-up pages show a button
99
+ // for each
100
+ export const socialProviders = Object.keys(
101
+ auth.options.socialProviders
102
+ ) as SocialProvider[];
@@ -0,0 +1,43 @@
1
+ // Sends through Resend when RESEND_API_KEY is set. Without it the email is
2
+ // printed to the server log, so verification and reset links can be copied
3
+ // from the terminal during development
4
+ export async function sendEmail({
5
+ to,
6
+ subject,
7
+ text,
8
+ }: {
9
+ to: string;
10
+ subject: string;
11
+ text: string;
12
+ }): Promise<void> {
13
+ const apiKey = process.env.RESEND_API_KEY;
14
+
15
+ if (!apiKey) {
16
+ console.log(`
17
+ --- Email to ${to}: ${subject}
18
+ ${text}
19
+ ---
20
+ `);
21
+ return;
22
+ }
23
+
24
+ const response = await fetch("https://api.resend.com/emails", {
25
+ method: "POST",
26
+ headers: {
27
+ Authorization: `Bearer ${apiKey}`,
28
+ "Content-Type": "application/json",
29
+ },
30
+ body: JSON.stringify({
31
+ from: process.env.EMAIL_FROM || "onboarding@resend.dev",
32
+ to,
33
+ subject,
34
+ text,
35
+ }),
36
+ });
37
+
38
+ if (!response.ok) {
39
+ throw new Error(
40
+ `Resend rejected the email (${response.status}): ${await response.text()}`
41
+ );
42
+ }
43
+ }
@@ -0,0 +1,12 @@
1
+ import { cache } from "react";
2
+
3
+ import { headers } from "next/headers";
4
+
5
+ import { auth } from "./auth";
6
+
7
+ // The session of the current request, checked on the server. cache() runs the
8
+ // lookup once per request, however many components ask for it, so each part of
9
+ // a page can read the session inside its own <Suspense> boundary
10
+ export const getSession = cache(async () =>
11
+ auth.api.getSession({ headers: await headers() })
12
+ );
@@ -1,19 +1,25 @@
1
- {
2
- "id": "auth",
3
- "name": "Better Auth",
4
- "description": "Better Auth for TanStack Start with optional Prisma, Drizzle, or MongoDB adapter",
5
- "type": "feature",
6
- "dependencies": {
7
- "@hookform/resolvers": "^5.9.2",
8
- "better-auth": "^1.7.14",
9
- "dotenv": "^17.4.2",
10
- "react-hook-form": "^7.91.0",
11
- "zod": "^4.6.7"
12
- },
13
- "devDependencies": {},
14
- "scripts": {},
15
- "envVars": {
16
- "BETTER_AUTH_SECRET": "please-change-me-to-a-random-string",
17
- "BETTER_AUTH_URL": "http://localhost:3000"
18
- }
19
- }
1
+ {
2
+ "id": "auth",
3
+ "name": "Better Auth",
4
+ "description": "Better Auth with email verification, password reset, and GitHub and Google sign-in for TanStack Start with optional Prisma, Drizzle, or MongoDB adapter",
5
+ "type": "feature",
6
+ "dependencies": {
7
+ "@hookform/resolvers": "^5.9.2",
8
+ "better-auth": "^1.7.14",
9
+ "dotenv": "^17.4.2",
10
+ "react-hook-form": "^7.91.0",
11
+ "zod": "^4.6.7"
12
+ },
13
+ "devDependencies": {},
14
+ "scripts": {},
15
+ "envVars": {
16
+ "BETTER_AUTH_SECRET": "please-change-me-to-a-random-string",
17
+ "BETTER_AUTH_URL": "http://localhost:3000",
18
+ "GITHUB_CLIENT_ID": "",
19
+ "GITHUB_CLIENT_SECRET": "",
20
+ "GOOGLE_CLIENT_ID": "",
21
+ "GOOGLE_CLIENT_SECRET": "",
22
+ "RESEND_API_KEY": "",
23
+ "EMAIL_FROM": "onboarding@resend.dev"
24
+ }
25
+ }
@@ -0,0 +1,123 @@
1
+ import { useState, useTransition } from "react";
2
+ import { zodResolver } from "@hookform/resolvers/zod";
3
+ import { CircleAlert, CircleCheck } from "lucide-react";
4
+ import { useForm } from "react-hook-form";
5
+
6
+ import { authClient } from "~/lib/auth-client";
7
+ import {
8
+ changePasswordSchema,
9
+ getAuthErrorMessage,
10
+ type ChangePasswordValues,
11
+ } from "~/lib/auth-schema";
12
+ import { Alert, AlertDescription } from "~/components/ui/alert";
13
+ import { Button } from "~/components/ui/button";
14
+ import {
15
+ Card,
16
+ CardContent,
17
+ CardDescription,
18
+ CardHeader,
19
+ CardTitle,
20
+ } from "~/components/ui/card";
21
+ import {
22
+ Field,
23
+ FieldError,
24
+ FieldGroup,
25
+ FieldLabel,
26
+ } from "~/components/ui/field";
27
+ import { Input } from "~/components/ui/input";
28
+ import { Spinner } from "~/components/ui/spinner";
29
+
30
+ export function ChangePasswordForm() {
31
+ const [isPending, startTransition] = useTransition();
32
+ const [message, setMessage] = useState<{
33
+ text: string;
34
+ isError: boolean;
35
+ } | null>(null);
36
+ const form = useForm<ChangePasswordValues>({
37
+ resolver: zodResolver(changePasswordSchema),
38
+ defaultValues: { currentPassword: "", newPassword: "" },
39
+ });
40
+
41
+ const onSubmit = form.handleSubmit((values) => {
42
+ setMessage(null);
43
+
44
+ startTransition(async () => {
45
+ // Other devices are signed out; this one stays signed in
46
+ const result = await authClient.changePassword({
47
+ currentPassword: values.currentPassword,
48
+ newPassword: values.newPassword,
49
+ revokeOtherSessions: true,
50
+ });
51
+
52
+ if (result.error) {
53
+ setMessage({ text: getAuthErrorMessage(result.error), isError: true });
54
+ return;
55
+ }
56
+
57
+ form.reset();
58
+ setMessage({ text: "Password changed.", isError: false });
59
+ });
60
+ });
61
+
62
+ return (
63
+ <Card>
64
+ <CardHeader>
65
+ <CardTitle>Change password</CardTitle>
66
+ <CardDescription>
67
+ Other devices are signed out after the change.
68
+ </CardDescription>
69
+ </CardHeader>
70
+ <CardContent>
71
+ <form onSubmit={onSubmit} noValidate>
72
+ <FieldGroup>
73
+ <Field
74
+ data-invalid={Boolean(form.formState.errors.currentPassword)}
75
+ >
76
+ <FieldLabel htmlFor="currentPassword">Current password</FieldLabel>
77
+ <Input
78
+ id="currentPassword"
79
+ type="password"
80
+ autoComplete="current-password"
81
+ aria-invalid={Boolean(form.formState.errors.currentPassword)}
82
+ {...form.register("currentPassword")}
83
+ />
84
+ <FieldError errors={[form.formState.errors.currentPassword]} />
85
+ </Field>
86
+
87
+ <Field data-invalid={Boolean(form.formState.errors.newPassword)}>
88
+ <FieldLabel htmlFor="newPassword">New password</FieldLabel>
89
+ <Input
90
+ id="newPassword"
91
+ type="password"
92
+ autoComplete="new-password"
93
+ aria-invalid={Boolean(form.formState.errors.newPassword)}
94
+ {...form.register("newPassword")}
95
+ />
96
+ <FieldError errors={[form.formState.errors.newPassword]} />
97
+ </Field>
98
+
99
+ {message ? (
100
+ <Alert variant={message.isError ? "destructive" : "default"}>
101
+ {message.isError ? <CircleAlert /> : <CircleCheck />}
102
+ <AlertDescription>{message.text}</AlertDescription>
103
+ </Alert>
104
+ ) : null}
105
+
106
+ <Field orientation="horizontal">
107
+ <Button type="submit" disabled={isPending}>
108
+ {isPending ? (
109
+ <>
110
+ <Spinner data-icon="inline-start" />
111
+ Saving
112
+ </>
113
+ ) : (
114
+ "Change password"
115
+ )}
116
+ </Button>
117
+ </Field>
118
+ </FieldGroup>
119
+ </form>
120
+ </CardContent>
121
+ </Card>
122
+ );
123
+ }
@@ -0,0 +1,89 @@
1
+ import { useState, useTransition } from "react";
2
+ import { BadgeCheck, MailWarning } from "lucide-react";
3
+
4
+ import { authClient } from "~/lib/auth-client";
5
+ import { getAuthErrorMessage } from "~/lib/auth-schema";
6
+ import { Badge } from "~/components/ui/badge";
7
+ import { Button } from "~/components/ui/button";
8
+ import {
9
+ Card,
10
+ CardAction,
11
+ CardContent,
12
+ CardDescription,
13
+ CardHeader,
14
+ CardTitle,
15
+ } from "~/components/ui/card";
16
+ import { Spinner } from "~/components/ui/spinner";
17
+
18
+ // Shows whether the email address is verified, with a button to send the
19
+ // verification email again
20
+ export function EmailVerificationCard({
21
+ email,
22
+ emailVerified,
23
+ }: {
24
+ email: string;
25
+ emailVerified: boolean;
26
+ }) {
27
+ const [isPending, startTransition] = useTransition();
28
+ const [message, setMessage] = useState<string | null>(null);
29
+
30
+ function resend() {
31
+ setMessage(null);
32
+ startTransition(async () => {
33
+ const result = await authClient.sendVerificationEmail({
34
+ email,
35
+ callbackURL: "/dashboard",
36
+ });
37
+ setMessage(
38
+ result.error
39
+ ? getAuthErrorMessage(result.error)
40
+ : "Verification email sent. Without an email provider set up, the link is printed in the server terminal."
41
+ );
42
+ });
43
+ }
44
+
45
+ return (
46
+ <Card>
47
+ <CardHeader>
48
+ <CardTitle>
49
+ {emailVerified ? "Email verified" : "Verify your email"}
50
+ </CardTitle>
51
+ <CardDescription>
52
+ {emailVerified
53
+ ? `${email} is confirmed.`
54
+ : `We sent a link to ${email}. Open it to confirm the address.`}
55
+ </CardDescription>
56
+ <CardAction>
57
+ {emailVerified ? (
58
+ <Badge variant="secondary">
59
+ <BadgeCheck data-icon="inline-start" />
60
+ Verified
61
+ </Badge>
62
+ ) : (
63
+ <Badge variant="outline">
64
+ <MailWarning data-icon="inline-start" />
65
+ Pending
66
+ </Badge>
67
+ )}
68
+ </CardAction>
69
+ </CardHeader>
70
+ {emailVerified ? null : (
71
+ <CardContent className="flex flex-col items-start gap-3">
72
+ <Button variant="outline" onClick={resend} disabled={isPending}>
73
+ {isPending ? (
74
+ <>
75
+ <Spinner data-icon="inline-start" />
76
+ Sending
77
+ </>
78
+ ) : (
79
+ "Send the email again"
80
+ )}
81
+ </Button>
82
+ {message ? (
83
+ <p className="text-sm text-muted-foreground">{message}</p>
84
+ ) : null}
85
+ </CardContent>
86
+ )}
87
+ </Card>
88
+ );
89
+ }
@@ -0,0 +1,131 @@
1
+ import { Link } from "@tanstack/react-router";
2
+ import { useState, useTransition } from "react";
3
+ import { zodResolver } from "@hookform/resolvers/zod";
4
+ import { CircleAlert, MailCheck } from "lucide-react";
5
+ import { useForm } from "react-hook-form";
6
+
7
+ import { authClient } from "~/lib/auth-client";
8
+ import {
9
+ forgotPasswordSchema,
10
+ getAuthErrorMessage,
11
+ type ForgotPasswordValues,
12
+ } from "~/lib/auth-schema";
13
+ import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
14
+ import { Button } from "~/components/ui/button";
15
+ import {
16
+ Card,
17
+ CardContent,
18
+ CardDescription,
19
+ CardHeader,
20
+ CardTitle,
21
+ } from "~/components/ui/card";
22
+ import {
23
+ Field,
24
+ FieldDescription,
25
+ FieldError,
26
+ FieldGroup,
27
+ FieldLabel,
28
+ } from "~/components/ui/field";
29
+ import { Input } from "~/components/ui/input";
30
+ import { Spinner } from "~/components/ui/spinner";
31
+
32
+ export function ForgotPasswordForm() {
33
+ const [isPending, startTransition] = useTransition();
34
+ const [authError, setAuthError] = useState<string | null>(null);
35
+ const [isSent, setIsSent] = useState(false);
36
+ const form = useForm<ForgotPasswordValues>({
37
+ resolver: zodResolver(forgotPasswordSchema),
38
+ defaultValues: { email: "" },
39
+ });
40
+
41
+ const onSubmit = form.handleSubmit((values) => {
42
+ setAuthError(null);
43
+
44
+ startTransition(async () => {
45
+ // The link in the email opens /reset-password with a one-time token
46
+ const result = await authClient.requestPasswordReset({
47
+ email: values.email,
48
+ redirectTo: "/reset-password",
49
+ });
50
+
51
+ if (result.error) {
52
+ setAuthError(getAuthErrorMessage(result.error));
53
+ return;
54
+ }
55
+
56
+ setIsSent(true);
57
+ });
58
+ });
59
+
60
+ return (
61
+ <Card>
62
+ <CardHeader>
63
+ <CardTitle>Forgot your password?</CardTitle>
64
+ <CardDescription>
65
+ Enter your email and we will send you a link to choose a new one.
66
+ </CardDescription>
67
+ </CardHeader>
68
+ <CardContent>
69
+ {isSent ? (
70
+ <FieldGroup>
71
+ <Alert>
72
+ <MailCheck />
73
+ <AlertTitle>Check your email</AlertTitle>
74
+ <AlertDescription>
75
+ If an account exists for that email, a reset link is on its
76
+ way. Without an email provider set up, the link is printed in
77
+ the server terminal.
78
+ </AlertDescription>
79
+ </Alert>
80
+ <FieldDescription className="text-center">
81
+ <Link to="/sign-in">Back to login</Link>
82
+ </FieldDescription>
83
+ </FieldGroup>
84
+ ) : (
85
+ <form onSubmit={onSubmit} noValidate>
86
+ <FieldGroup>
87
+ <Field data-invalid={Boolean(form.formState.errors.email)}>
88
+ <FieldLabel htmlFor="email">Email</FieldLabel>
89
+ <Input
90
+ id="email"
91
+ type="email"
92
+ autoComplete="email"
93
+ aria-invalid={Boolean(form.formState.errors.email)}
94
+ placeholder="you@example.com"
95
+ {...form.register("email")}
96
+ />
97
+ <FieldDescription>
98
+ Use the email you registered with.
99
+ </FieldDescription>
100
+ <FieldError errors={[form.formState.errors.email]} />
101
+ </Field>
102
+
103
+ {authError ? (
104
+ <Alert variant="destructive">
105
+ <CircleAlert />
106
+ <AlertDescription>{authError}</AlertDescription>
107
+ </Alert>
108
+ ) : null}
109
+
110
+ <Field>
111
+ <Button type="submit" disabled={isPending}>
112
+ {isPending ? (
113
+ <>
114
+ <Spinner data-icon="inline-start" />
115
+ Sending link
116
+ </>
117
+ ) : (
118
+ "Send reset link"
119
+ )}
120
+ </Button>
121
+ <FieldDescription className="text-center">
122
+ Remembered it? <Link to="/sign-in">Back to login</Link>
123
+ </FieldDescription>
124
+ </Field>
125
+ </FieldGroup>
126
+ </form>
127
+ )}
128
+ </CardContent>
129
+ </Card>
130
+ );
131
+ }