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
@@ -1,35 +1,41 @@
1
- import { headers } from "next/headers";
1
+ import { Suspense } from "react";
2
+
2
3
  import { redirect } from "next/navigation";
3
4
 
4
5
  import { SignInForm } from "@/components/auth/sign-in-form";
5
- import { auth } from "@/lib/auth";
6
-
7
- export default async function SignInPage() {
8
- const session = await auth.api.getSession({
9
- headers: await headers(),
10
- });
6
+ import { Badge } from "@/components/ui/badge";
7
+ import { socialProviders } from "@/lib/auth";
8
+ import { getSession } from "@/lib/session";
11
9
 
12
- if (session) {
10
+ // Sends a signed-in visitor to the dashboard. It runs in its own <Suspense>
11
+ // boundary, so the form is sent without waiting for the session lookup
12
+ async function RedirectIfSignedIn() {
13
+ if (await getSession()) {
13
14
  redirect("/dashboard");
14
15
  }
15
16
 
17
+ return null;
18
+ }
19
+
20
+ export default function SignInPage() {
16
21
  return (
17
- <main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(14,165,233,0.16),_transparent_30%),linear-gradient(180deg,_#f8fafc_0%,_#eef2ff_100%)] px-6 py-16 sm:px-10">
18
- <div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-5xl items-center gap-12 lg:grid lg:grid-cols-[0.9fr_1.1fr]">
19
- <section className="hidden space-y-5 lg:block">
20
- <span className="inline-flex rounded-full border border-sky-200 bg-white/80 px-3 py-1 text-xs font-medium uppercase tracking-[0.24em] text-sky-700">
21
- Better Auth
22
- </span>
23
- <h1 className="text-4xl font-semibold tracking-tight text-slate-950">
22
+ <main className="flex min-h-svh items-center justify-center bg-muted p-6 md:p-10">
23
+ <div className="grid w-full max-w-5xl items-center gap-12 lg:grid-cols-[0.9fr_1.1fr]">
24
+ <section className="hidden flex-col gap-5 lg:flex">
25
+ <Badge variant="outline">Better Auth</Badge>
26
+ <h1 className="text-4xl font-semibold tracking-tight">
24
27
  Welcome back.
25
28
  </h1>
26
- <p className="max-w-md text-base leading-7 text-slate-600">
29
+ <p className="max-w-md text-base leading-7 text-muted-foreground">
27
30
  This generated starter includes protected routes, session-aware page
28
31
  guards, and reusable auth form primitives you can extend from here.
29
32
  </p>
30
33
  </section>
31
- <SignInForm />
34
+ <SignInForm providers={socialProviders} />
32
35
  </div>
36
+ <Suspense fallback={null}>
37
+ <RedirectIfSignedIn />
38
+ </Suspense>
33
39
  </main>
34
40
  );
35
41
  }
@@ -1,35 +1,41 @@
1
- import { headers } from "next/headers";
1
+ import { Suspense } from "react";
2
+
2
3
  import { redirect } from "next/navigation";
3
4
 
4
5
  import { SignUpForm } from "@/components/auth/sign-up-form";
5
- import { auth } from "@/lib/auth";
6
-
7
- export default async function SignUpPage() {
8
- const session = await auth.api.getSession({
9
- headers: await headers(),
10
- });
6
+ import { Badge } from "@/components/ui/badge";
7
+ import { socialProviders } from "@/lib/auth";
8
+ import { getSession } from "@/lib/session";
11
9
 
12
- if (session) {
10
+ // Sends a signed-in visitor to the dashboard. It runs in its own <Suspense>
11
+ // boundary, so the form is sent without waiting for the session lookup
12
+ async function RedirectIfSignedIn() {
13
+ if (await getSession()) {
13
14
  redirect("/dashboard");
14
15
  }
15
16
 
17
+ return null;
18
+ }
19
+
20
+ export default function SignUpPage() {
16
21
  return (
17
- <main className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(56,189,248,0.18),_transparent_28%),linear-gradient(180deg,_#f8fafc_0%,_#ecfeff_100%)] px-6 py-16 sm:px-10">
18
- <div className="mx-auto flex min-h-[calc(100vh-8rem)] max-w-5xl items-center gap-12 lg:grid lg:grid-cols-[0.9fr_1.1fr]">
19
- <section className="hidden space-y-5 lg:block">
20
- <span className="inline-flex rounded-full border border-sky-200 bg-white/80 px-3 py-1 text-xs font-medium uppercase tracking-[0.24em] text-sky-700">
21
- Better Auth
22
- </span>
23
- <h1 className="text-4xl font-semibold tracking-tight text-slate-950">
22
+ <main className="flex min-h-svh items-center justify-center bg-muted p-6 md:p-10">
23
+ <div className="grid w-full max-w-5xl items-center gap-12 lg:grid-cols-[0.9fr_1.1fr]">
24
+ <section className="hidden flex-col gap-5 lg:flex">
25
+ <Badge variant="outline">Better Auth</Badge>
26
+ <h1 className="text-4xl font-semibold tracking-tight">
24
27
  Create your account.
25
28
  </h1>
26
- <p className="max-w-md text-base leading-7 text-slate-600">
27
- Start with email and password today, then layer in profile data,
28
- onboarding, and provider-based auth when your product needs it.
29
+ <p className="max-w-md text-base leading-7 text-muted-foreground">
30
+ Sign up with email and password, or with GitHub or Google once their
31
+ keys are set. A verification link goes to your inbox.
29
32
  </p>
30
33
  </section>
31
- <SignUpForm />
34
+ <SignUpForm providers={socialProviders} />
32
35
  </div>
36
+ <Suspense fallback={null}>
37
+ <RedirectIfSignedIn />
38
+ </Suspense>
33
39
  </main>
34
40
  );
35
41
  }
@@ -0,0 +1,125 @@
1
+ "use client";
2
+
3
+ import { useState, useTransition } from "react";
4
+ import { zodResolver } from "@hookform/resolvers/zod";
5
+ import { CircleAlert, CircleCheck } from "lucide-react";
6
+ import { useForm } from "react-hook-form";
7
+
8
+ import { authClient } from "@/lib/auth-client";
9
+ import {
10
+ changePasswordSchema,
11
+ getAuthErrorMessage,
12
+ type ChangePasswordValues,
13
+ } from "@/lib/auth-schema";
14
+ import { Alert, AlertDescription } from "@/components/ui/alert";
15
+ import { Button } from "@/components/ui/button";
16
+ import {
17
+ Card,
18
+ CardContent,
19
+ CardDescription,
20
+ CardHeader,
21
+ CardTitle,
22
+ } from "@/components/ui/card";
23
+ import {
24
+ Field,
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 ChangePasswordForm() {
33
+ const [isPending, startTransition] = useTransition();
34
+ const [message, setMessage] = useState<{
35
+ text: string;
36
+ isError: boolean;
37
+ } | null>(null);
38
+ const form = useForm<ChangePasswordValues>({
39
+ resolver: zodResolver(changePasswordSchema),
40
+ defaultValues: { currentPassword: "", newPassword: "" },
41
+ });
42
+
43
+ const onSubmit = form.handleSubmit((values) => {
44
+ setMessage(null);
45
+
46
+ startTransition(async () => {
47
+ // Other devices are signed out; this one stays signed in
48
+ const result = await authClient.changePassword({
49
+ currentPassword: values.currentPassword,
50
+ newPassword: values.newPassword,
51
+ revokeOtherSessions: true,
52
+ });
53
+
54
+ if (result.error) {
55
+ setMessage({ text: getAuthErrorMessage(result.error), isError: true });
56
+ return;
57
+ }
58
+
59
+ form.reset();
60
+ setMessage({ text: "Password changed.", isError: false });
61
+ });
62
+ });
63
+
64
+ return (
65
+ <Card>
66
+ <CardHeader>
67
+ <CardTitle>Change password</CardTitle>
68
+ <CardDescription>
69
+ Other devices are signed out after the change.
70
+ </CardDescription>
71
+ </CardHeader>
72
+ <CardContent>
73
+ <form onSubmit={onSubmit} noValidate>
74
+ <FieldGroup>
75
+ <Field
76
+ data-invalid={Boolean(form.formState.errors.currentPassword)}
77
+ >
78
+ <FieldLabel htmlFor="currentPassword">Current password</FieldLabel>
79
+ <Input
80
+ id="currentPassword"
81
+ type="password"
82
+ autoComplete="current-password"
83
+ aria-invalid={Boolean(form.formState.errors.currentPassword)}
84
+ {...form.register("currentPassword")}
85
+ />
86
+ <FieldError errors={[form.formState.errors.currentPassword]} />
87
+ </Field>
88
+
89
+ <Field data-invalid={Boolean(form.formState.errors.newPassword)}>
90
+ <FieldLabel htmlFor="newPassword">New password</FieldLabel>
91
+ <Input
92
+ id="newPassword"
93
+ type="password"
94
+ autoComplete="new-password"
95
+ aria-invalid={Boolean(form.formState.errors.newPassword)}
96
+ {...form.register("newPassword")}
97
+ />
98
+ <FieldError errors={[form.formState.errors.newPassword]} />
99
+ </Field>
100
+
101
+ {message ? (
102
+ <Alert variant={message.isError ? "destructive" : "default"}>
103
+ {message.isError ? <CircleAlert /> : <CircleCheck />}
104
+ <AlertDescription>{message.text}</AlertDescription>
105
+ </Alert>
106
+ ) : null}
107
+
108
+ <Field orientation="horizontal">
109
+ <Button type="submit" disabled={isPending}>
110
+ {isPending ? (
111
+ <>
112
+ <Spinner data-icon="inline-start" />
113
+ Saving
114
+ </>
115
+ ) : (
116
+ "Change password"
117
+ )}
118
+ </Button>
119
+ </Field>
120
+ </FieldGroup>
121
+ </form>
122
+ </CardContent>
123
+ </Card>
124
+ );
125
+ }
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ import { useState, useTransition } from "react";
4
+ import { BadgeCheck, MailWarning } from "lucide-react";
5
+
6
+ import { authClient } from "@/lib/auth-client";
7
+ import { getAuthErrorMessage } from "@/lib/auth-schema";
8
+ import { Badge } from "@/components/ui/badge";
9
+ import { Button } from "@/components/ui/button";
10
+ import {
11
+ Card,
12
+ CardAction,
13
+ CardContent,
14
+ CardDescription,
15
+ CardHeader,
16
+ CardTitle,
17
+ } from "@/components/ui/card";
18
+ import { Spinner } from "@/components/ui/spinner";
19
+
20
+ // Shows whether the email address is verified, with a button to send the
21
+ // verification email again
22
+ export function EmailVerificationCard({
23
+ email,
24
+ emailVerified,
25
+ }: {
26
+ email: string;
27
+ emailVerified: boolean;
28
+ }) {
29
+ const [isPending, startTransition] = useTransition();
30
+ const [message, setMessage] = useState<string | null>(null);
31
+
32
+ function resend() {
33
+ setMessage(null);
34
+ startTransition(async () => {
35
+ const result = await authClient.sendVerificationEmail({
36
+ email,
37
+ callbackURL: "/dashboard",
38
+ });
39
+ setMessage(
40
+ result.error
41
+ ? getAuthErrorMessage(result.error)
42
+ : "Verification email sent. Without an email provider set up, the link is printed in the server terminal."
43
+ );
44
+ });
45
+ }
46
+
47
+ return (
48
+ <Card>
49
+ <CardHeader>
50
+ <CardTitle>
51
+ {emailVerified ? "Email verified" : "Verify your email"}
52
+ </CardTitle>
53
+ <CardDescription>
54
+ {emailVerified
55
+ ? `${email} is confirmed.`
56
+ : `We sent a link to ${email}. Open it to confirm the address.`}
57
+ </CardDescription>
58
+ <CardAction>
59
+ {emailVerified ? (
60
+ <Badge variant="secondary">
61
+ <BadgeCheck data-icon="inline-start" />
62
+ Verified
63
+ </Badge>
64
+ ) : (
65
+ <Badge variant="outline">
66
+ <MailWarning data-icon="inline-start" />
67
+ Pending
68
+ </Badge>
69
+ )}
70
+ </CardAction>
71
+ </CardHeader>
72
+ {emailVerified ? null : (
73
+ <CardContent className="flex flex-col items-start gap-3">
74
+ <Button variant="outline" onClick={resend} disabled={isPending}>
75
+ {isPending ? (
76
+ <>
77
+ <Spinner data-icon="inline-start" />
78
+ Sending
79
+ </>
80
+ ) : (
81
+ "Send the email again"
82
+ )}
83
+ </Button>
84
+ {message ? (
85
+ <p className="text-sm text-muted-foreground">{message}</p>
86
+ ) : null}
87
+ </CardContent>
88
+ )}
89
+ </Card>
90
+ );
91
+ }
@@ -0,0 +1,133 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useState, useTransition } from "react";
5
+ import { zodResolver } from "@hookform/resolvers/zod";
6
+ import { CircleAlert, MailCheck } from "lucide-react";
7
+ import { useForm } from "react-hook-form";
8
+
9
+ import { authClient } from "@/lib/auth-client";
10
+ import {
11
+ forgotPasswordSchema,
12
+ getAuthErrorMessage,
13
+ type ForgotPasswordValues,
14
+ } from "@/lib/auth-schema";
15
+ import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
16
+ import { Button } from "@/components/ui/button";
17
+ import {
18
+ Card,
19
+ CardContent,
20
+ CardDescription,
21
+ CardHeader,
22
+ CardTitle,
23
+ } from "@/components/ui/card";
24
+ import {
25
+ Field,
26
+ FieldDescription,
27
+ FieldError,
28
+ FieldGroup,
29
+ FieldLabel,
30
+ } from "@/components/ui/field";
31
+ import { Input } from "@/components/ui/input";
32
+ import { Spinner } from "@/components/ui/spinner";
33
+
34
+ export function ForgotPasswordForm() {
35
+ const [isPending, startTransition] = useTransition();
36
+ const [authError, setAuthError] = useState<string | null>(null);
37
+ const [isSent, setIsSent] = useState(false);
38
+ const form = useForm<ForgotPasswordValues>({
39
+ resolver: zodResolver(forgotPasswordSchema),
40
+ defaultValues: { email: "" },
41
+ });
42
+
43
+ const onSubmit = form.handleSubmit((values) => {
44
+ setAuthError(null);
45
+
46
+ startTransition(async () => {
47
+ // The link in the email opens /reset-password with a one-time token
48
+ const result = await authClient.requestPasswordReset({
49
+ email: values.email,
50
+ redirectTo: "/reset-password",
51
+ });
52
+
53
+ if (result.error) {
54
+ setAuthError(getAuthErrorMessage(result.error));
55
+ return;
56
+ }
57
+
58
+ setIsSent(true);
59
+ });
60
+ });
61
+
62
+ return (
63
+ <Card>
64
+ <CardHeader>
65
+ <CardTitle>Forgot your password?</CardTitle>
66
+ <CardDescription>
67
+ Enter your email and we will send you a link to choose a new one.
68
+ </CardDescription>
69
+ </CardHeader>
70
+ <CardContent>
71
+ {isSent ? (
72
+ <FieldGroup>
73
+ <Alert>
74
+ <MailCheck />
75
+ <AlertTitle>Check your email</AlertTitle>
76
+ <AlertDescription>
77
+ If an account exists for that email, a reset link is on its
78
+ way. Without an email provider set up, the link is printed in
79
+ the server terminal.
80
+ </AlertDescription>
81
+ </Alert>
82
+ <FieldDescription className="text-center">
83
+ <Link href="/sign-in">Back to login</Link>
84
+ </FieldDescription>
85
+ </FieldGroup>
86
+ ) : (
87
+ <form onSubmit={onSubmit} noValidate>
88
+ <FieldGroup>
89
+ <Field data-invalid={Boolean(form.formState.errors.email)}>
90
+ <FieldLabel htmlFor="email">Email</FieldLabel>
91
+ <Input
92
+ id="email"
93
+ type="email"
94
+ autoComplete="email"
95
+ aria-invalid={Boolean(form.formState.errors.email)}
96
+ placeholder="you@example.com"
97
+ {...form.register("email")}
98
+ />
99
+ <FieldDescription>
100
+ Use the email you registered with.
101
+ </FieldDescription>
102
+ <FieldError errors={[form.formState.errors.email]} />
103
+ </Field>
104
+
105
+ {authError ? (
106
+ <Alert variant="destructive">
107
+ <CircleAlert />
108
+ <AlertDescription>{authError}</AlertDescription>
109
+ </Alert>
110
+ ) : null}
111
+
112
+ <Field>
113
+ <Button type="submit" disabled={isPending}>
114
+ {isPending ? (
115
+ <>
116
+ <Spinner data-icon="inline-start" />
117
+ Sending link
118
+ </>
119
+ ) : (
120
+ "Send reset link"
121
+ )}
122
+ </Button>
123
+ <FieldDescription className="text-center">
124
+ Remembered it? <Link href="/sign-in">Back to login</Link>
125
+ </FieldDescription>
126
+ </Field>
127
+ </FieldGroup>
128
+ </form>
129
+ )}
130
+ </CardContent>
131
+ </Card>
132
+ );
133
+ }
@@ -0,0 +1,155 @@
1
+ "use client";
2
+
3
+ import Link from "next/link";
4
+ import { useSearchParams } from "next/navigation";
5
+ import { useState, useTransition } from "react";
6
+ import { zodResolver } from "@hookform/resolvers/zod";
7
+ import { CircleAlert } from "lucide-react";
8
+ import { useForm } from "react-hook-form";
9
+
10
+ import { authClient } from "@/lib/auth-client";
11
+ import {
12
+ getAuthErrorMessage,
13
+ resetPasswordSchema,
14
+ type ResetPasswordValues,
15
+ } from "@/lib/auth-schema";
16
+ import { Alert, AlertDescription } from "@/components/ui/alert";
17
+ import { Button } from "@/components/ui/button";
18
+ import {
19
+ Card,
20
+ CardContent,
21
+ CardDescription,
22
+ CardFooter,
23
+ CardHeader,
24
+ CardTitle,
25
+ } from "@/components/ui/card";
26
+ import {
27
+ Field,
28
+ FieldError,
29
+ FieldGroup,
30
+ FieldLabel,
31
+ } from "@/components/ui/field";
32
+ import { Input } from "@/components/ui/input";
33
+ import { Spinner } from "@/components/ui/spinner";
34
+
35
+ // The link in the reset email opens /reset-password?token=..., or
36
+ // ?error=INVALID_TOKEN when the token is invalid or has expired. Reading it here,
37
+ // in the browser, lets the page itself be static
38
+ export function ResetPasswordForm() {
39
+ const searchParams = useSearchParams();
40
+ const token = searchParams.get("error") ? null : searchParams.get("token");
41
+ const [isPending, startTransition] = useTransition();
42
+ const [authError, setAuthError] = useState<string | null>(null);
43
+ const [isDone, setIsDone] = useState(false);
44
+ const form = useForm<ResetPasswordValues>({
45
+ resolver: zodResolver(resetPasswordSchema),
46
+ defaultValues: { password: "", confirmPassword: "" },
47
+ });
48
+
49
+ const onSubmit = form.handleSubmit((values) => {
50
+ if (!token) return;
51
+ setAuthError(null);
52
+
53
+ startTransition(async () => {
54
+ const result = await authClient.resetPassword({
55
+ newPassword: values.password,
56
+ token,
57
+ });
58
+
59
+ if (result.error) {
60
+ setAuthError(getAuthErrorMessage(result.error));
61
+ return;
62
+ }
63
+
64
+ setIsDone(true);
65
+ });
66
+ });
67
+
68
+ if (!token || isDone) {
69
+ return (
70
+ <Card>
71
+ <CardHeader>
72
+ <CardTitle>{isDone ? "Password changed" : "Link expired"}</CardTitle>
73
+ <CardDescription>
74
+ {isDone
75
+ ? "You were signed out on every device. Log in with your new password."
76
+ : "This reset link is invalid or has expired. Ask for a new one."}
77
+ </CardDescription>
78
+ </CardHeader>
79
+ <CardFooter>
80
+ <Button
81
+ className="w-full"
82
+ nativeButton={false}
83
+ render={<Link href={isDone ? "/sign-in" : "/forgot-password"} />}
84
+ >
85
+ {isDone ? "Go to login" : "Send a new link"}
86
+ </Button>
87
+ </CardFooter>
88
+ </Card>
89
+ );
90
+ }
91
+
92
+ return (
93
+ <Card>
94
+ <CardHeader>
95
+ <CardTitle>Choose a new password</CardTitle>
96
+ <CardDescription>
97
+ You will be signed out on every device after the change.
98
+ </CardDescription>
99
+ </CardHeader>
100
+ <CardContent>
101
+ <form onSubmit={onSubmit} noValidate>
102
+ <FieldGroup>
103
+ <Field data-invalid={Boolean(form.formState.errors.password)}>
104
+ <FieldLabel htmlFor="password">New password</FieldLabel>
105
+ <Input
106
+ id="password"
107
+ type="password"
108
+ autoComplete="new-password"
109
+ aria-invalid={Boolean(form.formState.errors.password)}
110
+ {...form.register("password")}
111
+ />
112
+ <FieldError errors={[form.formState.errors.password]} />
113
+ </Field>
114
+
115
+ <Field
116
+ data-invalid={Boolean(form.formState.errors.confirmPassword)}
117
+ >
118
+ <FieldLabel htmlFor="confirmPassword">
119
+ Confirm new password
120
+ </FieldLabel>
121
+ <Input
122
+ id="confirmPassword"
123
+ type="password"
124
+ autoComplete="new-password"
125
+ aria-invalid={Boolean(form.formState.errors.confirmPassword)}
126
+ {...form.register("confirmPassword")}
127
+ />
128
+ <FieldError errors={[form.formState.errors.confirmPassword]} />
129
+ </Field>
130
+
131
+ {authError ? (
132
+ <Alert variant="destructive">
133
+ <CircleAlert />
134
+ <AlertDescription>{authError}</AlertDescription>
135
+ </Alert>
136
+ ) : null}
137
+
138
+ <Field>
139
+ <Button type="submit" disabled={isPending}>
140
+ {isPending ? (
141
+ <>
142
+ <Spinner data-icon="inline-start" />
143
+ Saving
144
+ </>
145
+ ) : (
146
+ "Save new password"
147
+ )}
148
+ </Button>
149
+ </Field>
150
+ </FieldGroup>
151
+ </form>
152
+ </CardContent>
153
+ </Card>
154
+ );
155
+ }