create-stackrjs 1.4.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 (113) hide show
  1. package/README.md +154 -0
  2. package/dist/index.js +1135 -0
  3. package/package.json +62 -0
  4. package/template/architectures/microservices/_gitignore +26 -0
  5. package/template/architectures/microservices/apps/web/Dockerfile +11 -0
  6. package/template/architectures/microservices/apps/web/next-env.d.ts +5 -0
  7. package/template/architectures/microservices/apps/web/next.config.mjs +4 -0
  8. package/template/architectures/microservices/apps/web/package.json +25 -0
  9. package/template/architectures/microservices/apps/web/src/app/account/page.tsx +56 -0
  10. package/template/architectures/microservices/apps/web/src/app/globals.css +3 -0
  11. package/template/architectures/microservices/apps/web/src/app/layout.tsx +16 -0
  12. package/template/architectures/microservices/apps/web/src/app/login/page.tsx +70 -0
  13. package/template/architectures/microservices/apps/web/src/app/page.tsx +33 -0
  14. package/template/architectures/microservices/apps/web/src/app/register/page.tsx +71 -0
  15. package/template/architectures/microservices/apps/web/src/lib/api.ts +15 -0
  16. package/template/architectures/microservices/apps/web/tsconfig.json +23 -0
  17. package/template/architectures/microservices/docker-compose.yml +45 -0
  18. package/template/architectures/microservices/gateway/nginx.conf +20 -0
  19. package/template/architectures/microservices/package.json +21 -0
  20. package/template/architectures/microservices/services/api/Dockerfile +11 -0
  21. package/template/architectures/microservices/services/api/package.json +34 -0
  22. package/template/architectures/microservices/services/api/prisma/schema.prisma +17 -0
  23. package/template/architectures/microservices/services/api/prisma.config.ts +17 -0
  24. package/template/architectures/microservices/services/api/src/auth.ts +22 -0
  25. package/template/architectures/microservices/services/api/src/db.ts +12 -0
  26. package/template/architectures/microservices/services/api/src/index.ts +22 -0
  27. package/template/architectures/microservices/services/api/src/routes/auth.ts +73 -0
  28. package/template/architectures/microservices/services/api/tsconfig.json +16 -0
  29. package/template/base/_eslintrc.json +3 -0
  30. package/template/base/_gitignore +24 -0
  31. package/template/base/_prettierrc.json +6 -0
  32. package/template/base/next-env.d.ts +5 -0
  33. package/template/base/next.config.mjs +4 -0
  34. package/template/base/package.json +27 -0
  35. package/template/base/src/app/globals.css +14 -0
  36. package/template/base/src/app/layout.tsx +16 -0
  37. package/template/base/src/app/page.tsx +10 -0
  38. package/template/base/tsconfig.json +23 -0
  39. package/template/extras/account-better-auth/src/server/account.ts +52 -0
  40. package/template/extras/account-clerk/src/server/account.ts +64 -0
  41. package/template/extras/account-nextauth/src/server/account.ts +59 -0
  42. package/template/extras/account-supabase/src/lib/supabase/admin.ts +14 -0
  43. package/template/extras/account-supabase/src/server/account.ts +65 -0
  44. package/template/extras/admin/src/app/admin/page.tsx +55 -0
  45. package/template/extras/auth-pages/src/app/account/page.tsx +37 -0
  46. package/template/extras/auth-pages/src/app/forgot-password/page.tsx +33 -0
  47. package/template/extras/auth-pages/src/app/login/page.tsx +32 -0
  48. package/template/extras/auth-pages/src/app/page.tsx +46 -0
  49. package/template/extras/auth-pages/src/app/register/page.tsx +36 -0
  50. package/template/extras/auth-pages/src/app/reset-password/page.tsx +49 -0
  51. package/template/extras/auth-pages/src/components/auth/ui.tsx +57 -0
  52. package/template/extras/auth-pages-drizzle/src/server/actions.ts +122 -0
  53. package/template/extras/auth-pages-mongoose/src/server/actions.ts +121 -0
  54. package/template/extras/auth-pages-prisma/src/server/actions.ts +117 -0
  55. package/template/extras/better-auth/prisma/schema.prisma +70 -0
  56. package/template/extras/better-auth/src/app/account/page.tsx +42 -0
  57. package/template/extras/better-auth/src/app/api/auth/[...all]/route.ts +5 -0
  58. package/template/extras/better-auth/src/app/login/page.tsx +42 -0
  59. package/template/extras/better-auth/src/app/register/page.tsx +44 -0
  60. package/template/extras/better-auth/src/components/auth/ui.tsx +58 -0
  61. package/template/extras/better-auth/src/lib/auth-client.ts +7 -0
  62. package/template/extras/better-auth/src/server/auth.ts +13 -0
  63. package/template/extras/clerk/middleware.ts +14 -0
  64. package/template/extras/clerk/src/app/account/page.tsx +30 -0
  65. package/template/extras/clerk/src/app/layout.tsx +19 -0
  66. package/template/extras/clerk/src/app/page.tsx +40 -0
  67. package/template/extras/clerk/src/app/sign-in/[[...sign-in]]/page.tsx +9 -0
  68. package/template/extras/clerk/src/app/sign-up/[[...sign-up]]/page.tsx +9 -0
  69. package/template/extras/dashboard/src/app/dashboard/page.tsx +48 -0
  70. package/template/extras/databases/mongodb/docker-compose.yml +13 -0
  71. package/template/extras/databases/mysql/docker-compose.yml +14 -0
  72. package/template/extras/databases/postgres/docker-compose.yml +15 -0
  73. package/template/extras/drizzle/mysql/drizzle.config.ts +9 -0
  74. package/template/extras/drizzle/mysql/src/server/db.ts +11 -0
  75. package/template/extras/drizzle/mysql/src/server/schema.ts +17 -0
  76. package/template/extras/drizzle/postgres/drizzle.config.ts +9 -0
  77. package/template/extras/drizzle/postgres/src/server/db.ts +11 -0
  78. package/template/extras/drizzle/postgres/src/server/schema.ts +20 -0
  79. package/template/extras/landing/src/app/page.tsx +53 -0
  80. package/template/extras/mongoose/src/server/db.ts +12 -0
  81. package/template/extras/mongoose/src/server/models.ts +25 -0
  82. package/template/extras/nextauth/middleware.ts +11 -0
  83. package/template/extras/nextauth/src/app/api/auth/[...nextauth]/route.ts +3 -0
  84. package/template/extras/nextauth/src/server/auth.config.ts +39 -0
  85. package/template/extras/nextauth/src/server/roles.ts +5 -0
  86. package/template/extras/nextauth/src/types/next-auth.d.ts +22 -0
  87. package/template/extras/nextauth-drizzle/src/server/auth.ts +44 -0
  88. package/template/extras/nextauth-mongoose/src/server/auth.ts +42 -0
  89. package/template/extras/nextauth-prisma/src/server/auth.ts +43 -0
  90. package/template/extras/prisma/base/prisma/schema.prisma +67 -0
  91. package/template/extras/prisma/base/prisma.config.ts +10 -0
  92. package/template/extras/prisma/mysql/src/server/db.ts +25 -0
  93. package/template/extras/prisma/postgres/src/server/db.ts +18 -0
  94. package/template/extras/shadcn/components.json +20 -0
  95. package/template/extras/shadcn/postcss.config.mjs +7 -0
  96. package/template/extras/shadcn/src/app/globals.css +59 -0
  97. package/template/extras/shadcn/src/components/ui/button.tsx +42 -0
  98. package/template/extras/shadcn/src/components/ui/card.tsx +54 -0
  99. package/template/extras/shadcn/src/components/ui/input.tsx +19 -0
  100. package/template/extras/shadcn/src/lib/utils.ts +7 -0
  101. package/template/extras/shadcn/tailwind.config.ts +71 -0
  102. package/template/extras/supabase/middleware.ts +13 -0
  103. package/template/extras/supabase/src/app/account/page.tsx +32 -0
  104. package/template/extras/supabase/src/app/login/page.tsx +47 -0
  105. package/template/extras/supabase/src/app/register/page.tsx +52 -0
  106. package/template/extras/supabase/src/components/auth/sign-out-button.tsx +25 -0
  107. package/template/extras/supabase/src/components/auth/ui.tsx +58 -0
  108. package/template/extras/supabase/src/lib/supabase/client.ts +9 -0
  109. package/template/extras/supabase/src/lib/supabase/middleware.ts +34 -0
  110. package/template/extras/supabase/src/lib/supabase/server.ts +29 -0
  111. package/template/extras/tailwind/postcss.config.mjs +7 -0
  112. package/template/extras/tailwind/src/app/globals.css +3 -0
  113. package/template/extras/tailwind/tailwind.config.ts +9 -0
@@ -0,0 +1,46 @@
1
+ import Link from "next/link";
2
+ import { auth } from "@/server/auth";
3
+
4
+ export default async function Home() {
5
+ const session = await auth();
6
+
7
+ return (
8
+ <main className="mx-auto flex min-h-screen max-w-2xl flex-col items-center justify-center gap-8 p-8 text-center">
9
+ <div className="space-y-3">
10
+ <h1 className="text-4xl font-bold tracking-tight">Welcome 👋</h1>
11
+ <p className="text-gray-600">
12
+ This app was generated by Stackr with a working authentication portal.
13
+ </p>
14
+ </div>
15
+
16
+ {session?.user ? (
17
+ <div className="flex flex-col items-center gap-3">
18
+ <p className="text-sm text-gray-500">
19
+ Signed in as <span className="font-medium text-gray-900">{session.user.email}</span>
20
+ </p>
21
+ <Link
22
+ href="/account"
23
+ className="rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
24
+ >
25
+ Go to account
26
+ </Link>
27
+ </div>
28
+ ) : (
29
+ <div className="flex gap-3">
30
+ <Link
31
+ href="/login"
32
+ className="rounded-md bg-gray-900 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
33
+ >
34
+ Sign in
35
+ </Link>
36
+ <Link
37
+ href="/register"
38
+ className="rounded-md border border-gray-300 px-4 py-2 text-sm font-medium text-gray-900 hover:bg-gray-100"
39
+ >
40
+ Create account
41
+ </Link>
42
+ </div>
43
+ )}
44
+ </main>
45
+ );
46
+ }
@@ -0,0 +1,36 @@
1
+ import { registerAction } from "@/server/actions";
2
+ import { Alert, AuthShell, Field, FooterLinks, SubmitButton, TextLink } from "@/components/auth/ui";
3
+
4
+ export default async function RegisterPage({
5
+ searchParams,
6
+ }: {
7
+ searchParams: Promise<{ error?: string }>;
8
+ }) {
9
+ const sp = await searchParams;
10
+
11
+ return (
12
+ <AuthShell title="Create account">
13
+ {sp.error && <Alert kind="error">{sp.error}</Alert>}
14
+
15
+ <form action={registerAction}>
16
+ <Field label="Name" name="name" type="text" autoComplete="name" required />
17
+ <Field label="Email" name="email" type="email" autoComplete="email" required />
18
+ <Field
19
+ label="Password"
20
+ name="password"
21
+ type="password"
22
+ autoComplete="new-password"
23
+ minLength={8}
24
+ required
25
+ />
26
+ <SubmitButton>Create account</SubmitButton>
27
+ </form>
28
+
29
+ <FooterLinks>
30
+ <p>
31
+ Already have an account? <TextLink href="/login">Sign in</TextLink>
32
+ </p>
33
+ </FooterLinks>
34
+ </AuthShell>
35
+ );
36
+ }
@@ -0,0 +1,49 @@
1
+ import { resetPasswordAction } from "@/server/actions";
2
+ import { Alert, AuthShell, Field, FooterLinks, SubmitButton, TextLink } from "@/components/auth/ui";
3
+
4
+ export default async function ResetPasswordPage({
5
+ searchParams,
6
+ }: {
7
+ searchParams: Promise<{ token?: string; error?: string }>;
8
+ }) {
9
+ const sp = await searchParams;
10
+ const token = sp.token ?? "";
11
+
12
+ if (!token) {
13
+ return (
14
+ <AuthShell title="Reset password">
15
+ <Alert kind="error">Missing reset token. Request a new link.</Alert>
16
+ <FooterLinks>
17
+ <p>
18
+ <TextLink href="/forgot-password">Request reset link</TextLink>
19
+ </p>
20
+ </FooterLinks>
21
+ </AuthShell>
22
+ );
23
+ }
24
+
25
+ return (
26
+ <AuthShell title="Choose a new password">
27
+ {sp.error && <Alert kind="error">{sp.error}</Alert>}
28
+
29
+ <form action={resetPasswordAction}>
30
+ <input type="hidden" name="token" value={token} />
31
+ <Field
32
+ label="New password"
33
+ name="password"
34
+ type="password"
35
+ autoComplete="new-password"
36
+ minLength={8}
37
+ required
38
+ />
39
+ <SubmitButton>Update password</SubmitButton>
40
+ </form>
41
+
42
+ <FooterLinks>
43
+ <p>
44
+ <TextLink href="/login">Back to sign in</TextLink>
45
+ </p>
46
+ </FooterLinks>
47
+ </AuthShell>
48
+ );
49
+ }
@@ -0,0 +1,57 @@
1
+ import type { ComponentProps, ReactNode } from "react";
2
+ import Link from "next/link";
3
+
4
+ /** Centered card shell shared by all auth pages. */
5
+ export function AuthShell({ title, children }: { title: string; children: ReactNode }) {
6
+ return (
7
+ <main className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
8
+ <div className="w-full max-w-sm rounded-xl border border-gray-200 bg-white p-8 shadow-sm">
9
+ <h1 className="mb-6 text-2xl font-semibold tracking-tight">{title}</h1>
10
+ {children}
11
+ </div>
12
+ </main>
13
+ );
14
+ }
15
+
16
+ export function Field({ label, ...props }: { label: string } & ComponentProps<"input">) {
17
+ return (
18
+ <label className="mb-4 block">
19
+ <span className="mb-1 block text-sm font-medium text-gray-700">{label}</span>
20
+ <input
21
+ {...props}
22
+ className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900"
23
+ />
24
+ </label>
25
+ );
26
+ }
27
+
28
+ export function SubmitButton({ children }: { children: ReactNode }) {
29
+ return (
30
+ <button
31
+ type="submit"
32
+ className="w-full rounded-md bg-gray-900 px-3 py-2 text-sm font-medium text-white transition hover:bg-gray-700"
33
+ >
34
+ {children}
35
+ </button>
36
+ );
37
+ }
38
+
39
+ export function Alert({ kind, children }: { kind: "error" | "success"; children: ReactNode }) {
40
+ const styles =
41
+ kind === "error"
42
+ ? "bg-red-50 text-red-700 border-red-200"
43
+ : "bg-green-50 text-green-700 border-green-200";
44
+ return <p className={`mb-4 rounded-md border px-3 py-2 text-sm ${styles}`}>{children}</p>;
45
+ }
46
+
47
+ export function FooterLinks({ children }: { children: ReactNode }) {
48
+ return <div className="mt-6 space-y-1 text-center text-sm text-gray-500">{children}</div>;
49
+ }
50
+
51
+ export function TextLink({ href, children }: { href: string; children: ReactNode }) {
52
+ return (
53
+ <Link href={href} className="font-medium text-gray-900 underline-offset-2 hover:underline">
54
+ {children}
55
+ </Link>
56
+ );
57
+ }
@@ -0,0 +1,122 @@
1
+ "use server";
2
+
3
+ import { redirect } from "next/navigation";
4
+ import { randomBytes, randomUUID } from "node:crypto";
5
+ import { AuthError } from "next-auth";
6
+ import bcrypt from "bcryptjs";
7
+ import { eq } from "drizzle-orm";
8
+ import { z } from "zod";
9
+ import { db } from "./db";
10
+ import { users, verificationTokens } from "./schema";
11
+ import { signIn, signOut } from "./auth";
12
+
13
+ const registerSchema = z.object({
14
+ name: z.string().min(1, "Name is required").max(100),
15
+ email: z.string().email("Enter a valid email"),
16
+ password: z.string().min(8, "Password must be at least 8 characters"),
17
+ });
18
+
19
+ const resetSchema = z.object({
20
+ token: z.string().min(1),
21
+ password: z.string().min(8, "Password must be at least 8 characters"),
22
+ });
23
+
24
+ function err(path: string, message: string): never {
25
+ redirect(`${path}?error=${encodeURIComponent(message)}`);
26
+ }
27
+
28
+ /** Create an account, then sign the user in. */
29
+ export async function registerAction(formData: FormData): Promise<void> {
30
+ const parsed = registerSchema.safeParse({
31
+ name: formData.get("name"),
32
+ email: formData.get("email"),
33
+ password: formData.get("password"),
34
+ });
35
+ if (!parsed.success) err("/register", parsed.error.issues[0]?.message ?? "Invalid input");
36
+
37
+ const { name, email, password } = parsed.data;
38
+
39
+ const [existing] = await db.select().from(users).where(eq(users.email, email)).limit(1);
40
+ if (existing) err("/register", "An account with this email already exists");
41
+
42
+ const passwordHash = await bcrypt.hash(password, 10);
43
+ await db.insert(users).values({ id: randomUUID(), name, email, passwordHash });
44
+
45
+ try {
46
+ await signIn("credentials", { email, password, redirectTo: "/account" });
47
+ } catch (error) {
48
+ if (error instanceof AuthError) redirect("/login?reset=1");
49
+ throw error; // re-throw Next's redirect
50
+ }
51
+ }
52
+
53
+ /** Authenticate with email + password. */
54
+ export async function loginAction(formData: FormData): Promise<void> {
55
+ const email = String(formData.get("email") ?? "");
56
+ const password = String(formData.get("password") ?? "");
57
+
58
+ try {
59
+ await signIn("credentials", { email, password, redirectTo: "/account" });
60
+ } catch (error) {
61
+ if (error instanceof AuthError) err("/login", "Invalid email or password");
62
+ throw error; // re-throw Next's redirect
63
+ }
64
+ }
65
+
66
+ export async function signOutAction(): Promise<void> {
67
+ await signOut({ redirectTo: "/login" });
68
+ }
69
+
70
+ /**
71
+ * Issue a password-reset token. No mailer is configured yet, so the reset link
72
+ * is logged to the server console (dev mode). We always report success to avoid
73
+ * revealing which emails exist.
74
+ */
75
+ export async function requestPasswordResetAction(formData: FormData): Promise<void> {
76
+ const parsed = z.string().email().safeParse(formData.get("email"));
77
+ if (!parsed.success) err("/forgot-password", "Enter a valid email");
78
+
79
+ const [user] = await db.select().from(users).where(eq(users.email, parsed.data)).limit(1);
80
+ if (user) {
81
+ const token = randomBytes(32).toString("hex");
82
+ const expires = new Date(Date.now() + 1000 * 60 * 30); // 30 minutes
83
+ await db.insert(verificationTokens).values({ identifier: parsed.data, token, expires });
84
+
85
+ const base = process.env.AUTH_URL ?? "http://localhost:3000";
86
+ console.log(`\n[Stackr] Password reset link for ${parsed.data}:\n${base}/reset-password?token=${token}\n`);
87
+ }
88
+
89
+ redirect("/forgot-password?sent=1");
90
+ }
91
+
92
+ /** Consume a reset token and set a new password. */
93
+ export async function resetPasswordAction(formData: FormData): Promise<void> {
94
+ const parsed = resetSchema.safeParse({
95
+ token: formData.get("token"),
96
+ password: formData.get("password"),
97
+ });
98
+ if (!parsed.success) {
99
+ const token = String(formData.get("token") ?? "");
100
+ redirect(
101
+ `/reset-password?token=${encodeURIComponent(token)}&error=${encodeURIComponent(
102
+ parsed.error.issues[0]?.message ?? "Invalid input",
103
+ )}`,
104
+ );
105
+ }
106
+
107
+ const { token, password } = parsed.data;
108
+ const [record] = await db
109
+ .select()
110
+ .from(verificationTokens)
111
+ .where(eq(verificationTokens.token, token))
112
+ .limit(1);
113
+ if (!record || record.expires < new Date()) {
114
+ err("/reset-password", "This reset link is invalid or has expired");
115
+ }
116
+
117
+ const passwordHash = await bcrypt.hash(password, 10);
118
+ await db.update(users).set({ passwordHash }).where(eq(users.email, record.identifier));
119
+ await db.delete(verificationTokens).where(eq(verificationTokens.token, token));
120
+
121
+ redirect("/login?reset=1");
122
+ }
@@ -0,0 +1,121 @@
1
+ "use server";
2
+
3
+ import { redirect } from "next/navigation";
4
+ import { randomBytes } from "node:crypto";
5
+ import { AuthError } from "next-auth";
6
+ import bcrypt from "bcryptjs";
7
+ import { z } from "zod";
8
+ import { dbConnect } from "./db";
9
+ import { User, VerificationToken } from "./models";
10
+ import { signIn, signOut } from "./auth";
11
+
12
+ const registerSchema = z.object({
13
+ name: z.string().min(1, "Name is required").max(100),
14
+ email: z.string().email("Enter a valid email"),
15
+ password: z.string().min(8, "Password must be at least 8 characters"),
16
+ });
17
+
18
+ const resetSchema = z.object({
19
+ token: z.string().min(1),
20
+ password: z.string().min(8, "Password must be at least 8 characters"),
21
+ });
22
+
23
+ function err(path: string, message: string): never {
24
+ redirect(`${path}?error=${encodeURIComponent(message)}`);
25
+ }
26
+
27
+ /** Create an account, then sign the user in. */
28
+ export async function registerAction(formData: FormData): Promise<void> {
29
+ const parsed = registerSchema.safeParse({
30
+ name: formData.get("name"),
31
+ email: formData.get("email"),
32
+ password: formData.get("password"),
33
+ });
34
+ if (!parsed.success) err("/register", parsed.error.issues[0]?.message ?? "Invalid input");
35
+
36
+ const { name, email, password } = parsed.data;
37
+
38
+ await dbConnect();
39
+ if (await User.findOne({ email })) {
40
+ err("/register", "An account with this email already exists");
41
+ }
42
+
43
+ const passwordHash = await bcrypt.hash(password, 10);
44
+ await User.create({ name, email, passwordHash });
45
+
46
+ try {
47
+ await signIn("credentials", { email, password, redirectTo: "/account" });
48
+ } catch (error) {
49
+ if (error instanceof AuthError) redirect("/login?reset=1");
50
+ throw error; // re-throw Next's redirect
51
+ }
52
+ }
53
+
54
+ /** Authenticate with email + password. */
55
+ export async function loginAction(formData: FormData): Promise<void> {
56
+ const email = String(formData.get("email") ?? "");
57
+ const password = String(formData.get("password") ?? "");
58
+
59
+ try {
60
+ await signIn("credentials", { email, password, redirectTo: "/account" });
61
+ } catch (error) {
62
+ if (error instanceof AuthError) err("/login", "Invalid email or password");
63
+ throw error; // re-throw Next's redirect
64
+ }
65
+ }
66
+
67
+ export async function signOutAction(): Promise<void> {
68
+ await signOut({ redirectTo: "/login" });
69
+ }
70
+
71
+ /**
72
+ * Issue a password-reset token. No mailer is configured yet, so the reset link
73
+ * is logged to the server console (dev mode). We always report success to avoid
74
+ * revealing which emails exist.
75
+ */
76
+ export async function requestPasswordResetAction(formData: FormData): Promise<void> {
77
+ const parsed = z.string().email().safeParse(formData.get("email"));
78
+ if (!parsed.success) err("/forgot-password", "Enter a valid email");
79
+
80
+ await dbConnect();
81
+ const user = await User.findOne({ email: parsed.data });
82
+ if (user) {
83
+ const token = randomBytes(32).toString("hex");
84
+ const expires = new Date(Date.now() + 1000 * 60 * 30); // 30 minutes
85
+ await VerificationToken.create({ identifier: parsed.data, token, expires });
86
+
87
+ const base = process.env.AUTH_URL ?? "http://localhost:3000";
88
+ console.log(`\n[Stackr] Password reset link for ${parsed.data}:\n${base}/reset-password?token=${token}\n`);
89
+ }
90
+
91
+ redirect("/forgot-password?sent=1");
92
+ }
93
+
94
+ /** Consume a reset token and set a new password. */
95
+ export async function resetPasswordAction(formData: FormData): Promise<void> {
96
+ const parsed = resetSchema.safeParse({
97
+ token: formData.get("token"),
98
+ password: formData.get("password"),
99
+ });
100
+ if (!parsed.success) {
101
+ const token = String(formData.get("token") ?? "");
102
+ redirect(
103
+ `/reset-password?token=${encodeURIComponent(token)}&error=${encodeURIComponent(
104
+ parsed.error.issues[0]?.message ?? "Invalid input",
105
+ )}`,
106
+ );
107
+ }
108
+
109
+ const { token, password } = parsed.data;
110
+ await dbConnect();
111
+ const record = await VerificationToken.findOne({ token });
112
+ if (!record || record.expires < new Date()) {
113
+ err("/reset-password", "This reset link is invalid or has expired");
114
+ }
115
+
116
+ const passwordHash = await bcrypt.hash(password, 10);
117
+ await User.updateOne({ email: record.identifier }, { passwordHash });
118
+ await VerificationToken.deleteOne({ token });
119
+
120
+ redirect("/login?reset=1");
121
+ }
@@ -0,0 +1,117 @@
1
+ "use server";
2
+
3
+ import { redirect } from "next/navigation";
4
+ import { randomBytes } from "node:crypto";
5
+ import { AuthError } from "next-auth";
6
+ import bcrypt from "bcryptjs";
7
+ import { z } from "zod";
8
+ import { db } from "./db";
9
+ import { signIn, signOut } from "./auth";
10
+
11
+ const registerSchema = z.object({
12
+ name: z.string().min(1, "Name is required").max(100),
13
+ email: z.string().email("Enter a valid email"),
14
+ password: z.string().min(8, "Password must be at least 8 characters"),
15
+ });
16
+
17
+ const resetSchema = z.object({
18
+ token: z.string().min(1),
19
+ password: z.string().min(8, "Password must be at least 8 characters"),
20
+ });
21
+
22
+ function err(path: string, message: string): never {
23
+ redirect(`${path}?error=${encodeURIComponent(message)}`);
24
+ }
25
+
26
+ /** Create an account, then sign the user in. */
27
+ export async function registerAction(formData: FormData): Promise<void> {
28
+ const parsed = registerSchema.safeParse({
29
+ name: formData.get("name"),
30
+ email: formData.get("email"),
31
+ password: formData.get("password"),
32
+ });
33
+ if (!parsed.success) err("/register", parsed.error.issues[0]?.message ?? "Invalid input");
34
+
35
+ const { name, email, password } = parsed.data;
36
+
37
+ if (await db.user.findUnique({ where: { email } })) {
38
+ err("/register", "An account with this email already exists");
39
+ }
40
+
41
+ const passwordHash = await bcrypt.hash(password, 10);
42
+ await db.user.create({ data: { name, email, passwordHash } });
43
+
44
+ try {
45
+ await signIn("credentials", { email, password, redirectTo: "/account" });
46
+ } catch (error) {
47
+ if (error instanceof AuthError) redirect("/login?reset=1");
48
+ throw error; // re-throw Next's redirect
49
+ }
50
+ }
51
+
52
+ /** Authenticate with email + password. */
53
+ export async function loginAction(formData: FormData): Promise<void> {
54
+ const email = String(formData.get("email") ?? "");
55
+ const password = String(formData.get("password") ?? "");
56
+
57
+ try {
58
+ await signIn("credentials", { email, password, redirectTo: "/account" });
59
+ } catch (error) {
60
+ if (error instanceof AuthError) err("/login", "Invalid email or password");
61
+ throw error; // re-throw Next's redirect
62
+ }
63
+ }
64
+
65
+ export async function signOutAction(): Promise<void> {
66
+ await signOut({ redirectTo: "/login" });
67
+ }
68
+
69
+ /**
70
+ * Issue a password-reset token. No mailer is configured yet, so the reset link
71
+ * is logged to the server console (dev mode). We always report success to avoid
72
+ * revealing which emails exist.
73
+ */
74
+ export async function requestPasswordResetAction(formData: FormData): Promise<void> {
75
+ const parsed = z.string().email().safeParse(formData.get("email"));
76
+ if (!parsed.success) err("/forgot-password", "Enter a valid email");
77
+
78
+ const user = await db.user.findUnique({ where: { email: parsed.data } });
79
+ if (user) {
80
+ const token = randomBytes(32).toString("hex");
81
+ const expires = new Date(Date.now() + 1000 * 60 * 30); // 30 minutes
82
+ await db.verificationToken.create({ data: { identifier: parsed.data, token, expires } });
83
+
84
+ const base = process.env.AUTH_URL ?? "http://localhost:3000";
85
+ console.log(`\n[Stackr] Password reset link for ${parsed.data}:\n${base}/reset-password?token=${token}\n`);
86
+ }
87
+
88
+ redirect("/forgot-password?sent=1");
89
+ }
90
+
91
+ /** Consume a reset token and set a new password. */
92
+ export async function resetPasswordAction(formData: FormData): Promise<void> {
93
+ const parsed = resetSchema.safeParse({
94
+ token: formData.get("token"),
95
+ password: formData.get("password"),
96
+ });
97
+ if (!parsed.success) {
98
+ const token = String(formData.get("token") ?? "");
99
+ redirect(
100
+ `/reset-password?token=${encodeURIComponent(token)}&error=${encodeURIComponent(
101
+ parsed.error.issues[0]?.message ?? "Invalid input",
102
+ )}`,
103
+ );
104
+ }
105
+
106
+ const { token, password } = parsed.data;
107
+ const record = await db.verificationToken.findUnique({ where: { token } });
108
+ if (!record || record.expires < new Date()) {
109
+ err("/reset-password", "This reset link is invalid or has expired");
110
+ }
111
+
112
+ const passwordHash = await bcrypt.hash(password, 10);
113
+ await db.user.update({ where: { email: record.identifier }, data: { passwordHash } });
114
+ await db.verificationToken.delete({ where: { token } });
115
+
116
+ redirect("/login?reset=1");
117
+ }
@@ -0,0 +1,70 @@
1
+ // Prisma schema generated by Stackr for Better Auth.
2
+ // Models follow Better Auth's core schema (user / session / account / verification);
3
+ // the `account.password` column stores the email+password credential hash.
4
+
5
+ generator client {
6
+ provider = "prisma-client"
7
+ output = "../src/generated/prisma"
8
+ }
9
+
10
+ datasource db {
11
+ provider = "postgresql"
12
+ }
13
+
14
+ model User {
15
+ id String @id
16
+ name String
17
+ email String @unique
18
+ emailVerified Boolean @default(false)
19
+ image String?
20
+ createdAt DateTime @default(now())
21
+ updatedAt DateTime @updatedAt
22
+ sessions Session[]
23
+ accounts Account[]
24
+
25
+ @@map("user")
26
+ }
27
+
28
+ model Session {
29
+ id String @id
30
+ expiresAt DateTime
31
+ token String @unique
32
+ ipAddress String?
33
+ userAgent String?
34
+ userId String
35
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
36
+ createdAt DateTime @default(now())
37
+ updatedAt DateTime @updatedAt
38
+
39
+ @@map("session")
40
+ }
41
+
42
+ model Account {
43
+ id String @id
44
+ accountId String
45
+ providerId String
46
+ userId String
47
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
48
+ accessToken String?
49
+ refreshToken String?
50
+ idToken String?
51
+ accessTokenExpiresAt DateTime?
52
+ refreshTokenExpiresAt DateTime?
53
+ scope String?
54
+ password String?
55
+ createdAt DateTime @default(now())
56
+ updatedAt DateTime @updatedAt
57
+
58
+ @@map("account")
59
+ }
60
+
61
+ model Verification {
62
+ id String @id
63
+ identifier String
64
+ value String
65
+ expiresAt DateTime
66
+ createdAt DateTime? @default(now())
67
+ updatedAt DateTime? @updatedAt
68
+
69
+ @@map("verification")
70
+ }
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import { authClient } from "@/lib/auth-client";
6
+
7
+ export default function AccountPage() {
8
+ const router = useRouter();
9
+ const { data: session, isPending } = authClient.useSession();
10
+
11
+ // Client-side guard; redirect once we know there is no session.
12
+ useEffect(() => {
13
+ if (!isPending && !session) router.push("/login");
14
+ }, [isPending, session, router]);
15
+
16
+ if (isPending || !session) return null;
17
+
18
+ return (
19
+ <main className="mx-auto flex min-h-screen max-w-md flex-col justify-center gap-6 p-8">
20
+ <div className="rounded-xl border border-gray-200 bg-white p-8 shadow-sm">
21
+ <h1 className="mb-4 text-2xl font-semibold tracking-tight">Your account</h1>
22
+ <dl className="space-y-2 text-sm">
23
+ <div className="flex justify-between">
24
+ <dt className="text-gray-500">Name</dt>
25
+ <dd className="font-medium">{session.user.name}</dd>
26
+ </div>
27
+ <div className="flex justify-between">
28
+ <dt className="text-gray-500">Email</dt>
29
+ <dd className="font-medium">{session.user.email}</dd>
30
+ </div>
31
+ </dl>
32
+ <button
33
+ type="button"
34
+ onClick={() => authClient.signOut().then(() => router.push("/login"))}
35
+ className="mt-6 w-full rounded-md bg-gray-900 px-3 py-2 text-sm font-medium text-white transition hover:bg-gray-700"
36
+ >
37
+ Sign out
38
+ </button>
39
+ </div>
40
+ </main>
41
+ );
42
+ }
@@ -0,0 +1,5 @@
1
+ import { auth } from "@/server/auth";
2
+ import { toNextJsHandler } from "better-auth/next-js";
3
+
4
+ // Mounts every Better Auth endpoint (sign-in, sign-up, sign-out, session, ...).
5
+ export const { GET, POST } = toNextJsHandler(auth);