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.
- package/README.md +154 -0
- package/dist/index.js +1135 -0
- package/package.json +62 -0
- package/template/architectures/microservices/_gitignore +26 -0
- package/template/architectures/microservices/apps/web/Dockerfile +11 -0
- package/template/architectures/microservices/apps/web/next-env.d.ts +5 -0
- package/template/architectures/microservices/apps/web/next.config.mjs +4 -0
- package/template/architectures/microservices/apps/web/package.json +25 -0
- package/template/architectures/microservices/apps/web/src/app/account/page.tsx +56 -0
- package/template/architectures/microservices/apps/web/src/app/globals.css +3 -0
- package/template/architectures/microservices/apps/web/src/app/layout.tsx +16 -0
- package/template/architectures/microservices/apps/web/src/app/login/page.tsx +70 -0
- package/template/architectures/microservices/apps/web/src/app/page.tsx +33 -0
- package/template/architectures/microservices/apps/web/src/app/register/page.tsx +71 -0
- package/template/architectures/microservices/apps/web/src/lib/api.ts +15 -0
- package/template/architectures/microservices/apps/web/tsconfig.json +23 -0
- package/template/architectures/microservices/docker-compose.yml +45 -0
- package/template/architectures/microservices/gateway/nginx.conf +20 -0
- package/template/architectures/microservices/package.json +21 -0
- package/template/architectures/microservices/services/api/Dockerfile +11 -0
- package/template/architectures/microservices/services/api/package.json +34 -0
- package/template/architectures/microservices/services/api/prisma/schema.prisma +17 -0
- package/template/architectures/microservices/services/api/prisma.config.ts +17 -0
- package/template/architectures/microservices/services/api/src/auth.ts +22 -0
- package/template/architectures/microservices/services/api/src/db.ts +12 -0
- package/template/architectures/microservices/services/api/src/index.ts +22 -0
- package/template/architectures/microservices/services/api/src/routes/auth.ts +73 -0
- package/template/architectures/microservices/services/api/tsconfig.json +16 -0
- package/template/base/_eslintrc.json +3 -0
- package/template/base/_gitignore +24 -0
- package/template/base/_prettierrc.json +6 -0
- package/template/base/next-env.d.ts +5 -0
- package/template/base/next.config.mjs +4 -0
- package/template/base/package.json +27 -0
- package/template/base/src/app/globals.css +14 -0
- package/template/base/src/app/layout.tsx +16 -0
- package/template/base/src/app/page.tsx +10 -0
- package/template/base/tsconfig.json +23 -0
- package/template/extras/account-better-auth/src/server/account.ts +52 -0
- package/template/extras/account-clerk/src/server/account.ts +64 -0
- package/template/extras/account-nextauth/src/server/account.ts +59 -0
- package/template/extras/account-supabase/src/lib/supabase/admin.ts +14 -0
- package/template/extras/account-supabase/src/server/account.ts +65 -0
- package/template/extras/admin/src/app/admin/page.tsx +55 -0
- package/template/extras/auth-pages/src/app/account/page.tsx +37 -0
- package/template/extras/auth-pages/src/app/forgot-password/page.tsx +33 -0
- package/template/extras/auth-pages/src/app/login/page.tsx +32 -0
- package/template/extras/auth-pages/src/app/page.tsx +46 -0
- package/template/extras/auth-pages/src/app/register/page.tsx +36 -0
- package/template/extras/auth-pages/src/app/reset-password/page.tsx +49 -0
- package/template/extras/auth-pages/src/components/auth/ui.tsx +57 -0
- package/template/extras/auth-pages-drizzle/src/server/actions.ts +122 -0
- package/template/extras/auth-pages-mongoose/src/server/actions.ts +121 -0
- package/template/extras/auth-pages-prisma/src/server/actions.ts +117 -0
- package/template/extras/better-auth/prisma/schema.prisma +70 -0
- package/template/extras/better-auth/src/app/account/page.tsx +42 -0
- package/template/extras/better-auth/src/app/api/auth/[...all]/route.ts +5 -0
- package/template/extras/better-auth/src/app/login/page.tsx +42 -0
- package/template/extras/better-auth/src/app/register/page.tsx +44 -0
- package/template/extras/better-auth/src/components/auth/ui.tsx +58 -0
- package/template/extras/better-auth/src/lib/auth-client.ts +7 -0
- package/template/extras/better-auth/src/server/auth.ts +13 -0
- package/template/extras/clerk/middleware.ts +14 -0
- package/template/extras/clerk/src/app/account/page.tsx +30 -0
- package/template/extras/clerk/src/app/layout.tsx +19 -0
- package/template/extras/clerk/src/app/page.tsx +40 -0
- package/template/extras/clerk/src/app/sign-in/[[...sign-in]]/page.tsx +9 -0
- package/template/extras/clerk/src/app/sign-up/[[...sign-up]]/page.tsx +9 -0
- package/template/extras/dashboard/src/app/dashboard/page.tsx +48 -0
- package/template/extras/databases/mongodb/docker-compose.yml +13 -0
- package/template/extras/databases/mysql/docker-compose.yml +14 -0
- package/template/extras/databases/postgres/docker-compose.yml +15 -0
- package/template/extras/drizzle/mysql/drizzle.config.ts +9 -0
- package/template/extras/drizzle/mysql/src/server/db.ts +11 -0
- package/template/extras/drizzle/mysql/src/server/schema.ts +17 -0
- package/template/extras/drizzle/postgres/drizzle.config.ts +9 -0
- package/template/extras/drizzle/postgres/src/server/db.ts +11 -0
- package/template/extras/drizzle/postgres/src/server/schema.ts +20 -0
- package/template/extras/landing/src/app/page.tsx +53 -0
- package/template/extras/mongoose/src/server/db.ts +12 -0
- package/template/extras/mongoose/src/server/models.ts +25 -0
- package/template/extras/nextauth/middleware.ts +11 -0
- package/template/extras/nextauth/src/app/api/auth/[...nextauth]/route.ts +3 -0
- package/template/extras/nextauth/src/server/auth.config.ts +39 -0
- package/template/extras/nextauth/src/server/roles.ts +5 -0
- package/template/extras/nextauth/src/types/next-auth.d.ts +22 -0
- package/template/extras/nextauth-drizzle/src/server/auth.ts +44 -0
- package/template/extras/nextauth-mongoose/src/server/auth.ts +42 -0
- package/template/extras/nextauth-prisma/src/server/auth.ts +43 -0
- package/template/extras/prisma/base/prisma/schema.prisma +67 -0
- package/template/extras/prisma/base/prisma.config.ts +10 -0
- package/template/extras/prisma/mysql/src/server/db.ts +25 -0
- package/template/extras/prisma/postgres/src/server/db.ts +18 -0
- package/template/extras/shadcn/components.json +20 -0
- package/template/extras/shadcn/postcss.config.mjs +7 -0
- package/template/extras/shadcn/src/app/globals.css +59 -0
- package/template/extras/shadcn/src/components/ui/button.tsx +42 -0
- package/template/extras/shadcn/src/components/ui/card.tsx +54 -0
- package/template/extras/shadcn/src/components/ui/input.tsx +19 -0
- package/template/extras/shadcn/src/lib/utils.ts +7 -0
- package/template/extras/shadcn/tailwind.config.ts +71 -0
- package/template/extras/supabase/middleware.ts +13 -0
- package/template/extras/supabase/src/app/account/page.tsx +32 -0
- package/template/extras/supabase/src/app/login/page.tsx +47 -0
- package/template/extras/supabase/src/app/register/page.tsx +52 -0
- package/template/extras/supabase/src/components/auth/sign-out-button.tsx +25 -0
- package/template/extras/supabase/src/components/auth/ui.tsx +58 -0
- package/template/extras/supabase/src/lib/supabase/client.ts +9 -0
- package/template/extras/supabase/src/lib/supabase/middleware.ts +34 -0
- package/template/extras/supabase/src/lib/supabase/server.ts +29 -0
- package/template/extras/tailwind/postcss.config.mjs +7 -0
- package/template/extras/tailwind/src/app/globals.css +3 -0
- 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
|
+
}
|