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.
- package/README.md +16 -8
- package/dist/args.js +2 -1
- package/dist/builder/buildPlanner.js +7 -1
- package/dist/builder/configGenerator.js +28 -3
- package/dist/builder/index.js +2 -1
- package/dist/builder/readmeGenerator.js +2 -2
- package/dist/output/nextSteps.js +2 -4
- package/dist/prompts/stack.js +3 -5
- package/dist/types/index.js +1 -1
- package/package.json +1 -1
- package/templates/backend/express/src/env.ts.hbs +13 -2
- package/templates/backend/express/src/index.ts.hbs +31 -2
- package/templates/backend/nestjs/src/app.module.ts.hbs +4 -1
- package/templates/backend/nestjs/src/env.ts.hbs +13 -2
- package/templates/backend/nestjs/src/health/health.controller.ts.hbs +7 -0
- package/templates/backend/nestjs/src/{main.ts → main.ts.hbs} +11 -0
- package/templates/frontend/nextjs/app/globals.css +119 -79
- package/templates/frontend/nextjs/app/layout.tsx +13 -11
- package/templates/frontend/nextjs/app/page.tsx.hbs +63 -43
- package/templates/frontend/nextjs/components/ui/alert.tsx +75 -0
- package/templates/frontend/nextjs/components/ui/badge.tsx +51 -0
- package/templates/frontend/nextjs/components/ui/button.tsx +33 -27
- package/templates/frontend/nextjs/components/ui/card.tsx +76 -34
- package/templates/frontend/nextjs/components/ui/field.tsx +203 -32
- package/templates/frontend/nextjs/components/ui/input.tsx +8 -7
- package/templates/frontend/nextjs/components/ui/label.tsx +19 -0
- package/templates/frontend/nextjs/components/ui/separator.tsx +24 -0
- package/templates/frontend/nextjs/components/ui/skeleton.tsx +13 -0
- package/templates/frontend/nextjs/components/ui/spinner.tsx +10 -0
- package/templates/frontend/nextjs/components.json +11 -5
- package/templates/frontend/nextjs/config.json +4 -3
- package/templates/frontend/nextjs/lib/utils.ts +1 -6
- package/templates/frontend/tanstack/components.json +12 -5
- package/templates/frontend/tanstack/config.json +5 -2
- package/templates/frontend/tanstack/src/components/ui/alert.tsx +75 -0
- package/templates/frontend/tanstack/src/components/ui/badge.tsx +51 -0
- package/templates/frontend/tanstack/src/components/ui/button.tsx +33 -27
- package/templates/frontend/tanstack/src/components/ui/card.tsx +76 -34
- package/templates/frontend/tanstack/src/components/ui/field.tsx +201 -32
- package/templates/frontend/tanstack/src/components/ui/input.tsx +8 -7
- package/templates/frontend/tanstack/src/components/ui/label.tsx +17 -0
- package/templates/frontend/tanstack/src/components/ui/separator.tsx +24 -0
- package/templates/frontend/tanstack/src/components/ui/skeleton.tsx +13 -0
- package/templates/frontend/tanstack/src/components/ui/spinner.tsx +10 -0
- package/templates/frontend/tanstack/src/lib/utils.ts +1 -6
- package/templates/frontend/tanstack/src/routes/index.tsx.hbs +64 -45
- package/templates/frontend/tanstack/src/styles/app.css +100 -55
- package/templates/modules/agents/AGENTS.md.hbs +29 -21
- package/templates/modules/auth/express/config.json +12 -11
- package/templates/modules/auth/express/src/lib/auth.ts.hbs +143 -0
- package/templates/modules/auth/express/src/lib/email.ts +37 -0
- package/templates/modules/auth/express/src/middleware/requireAuth.ts +29 -0
- package/templates/modules/auth/express/src/routes/me.ts +13 -0
- package/templates/modules/auth/nestjs/config.json +13 -11
- package/templates/modules/auth/nestjs/src/auth/auth.module.ts.hbs +64 -14
- package/templates/modules/auth/nestjs/src/auth/auth.ts.hbs +152 -0
- package/templates/modules/auth/nestjs/src/auth/email.ts +37 -0
- package/templates/modules/auth/nestjs/src/me/me.controller.ts +15 -0
- package/templates/modules/auth/nextjs/app/dashboard/page.tsx +101 -45
- package/templates/modules/auth/nextjs/app/forgot-password/page.tsx +11 -0
- package/templates/modules/auth/nextjs/app/reset-password/page.tsx +17 -0
- package/templates/modules/auth/nextjs/app/sign-in/page.tsx +23 -17
- package/templates/modules/auth/nextjs/app/sign-up/page.tsx +25 -19
- package/templates/modules/auth/nextjs/components/auth/change-password-form.tsx +125 -0
- package/templates/modules/auth/nextjs/components/auth/email-verification-card.tsx +91 -0
- package/templates/modules/auth/nextjs/components/auth/forgot-password-form.tsx +133 -0
- package/templates/modules/auth/nextjs/components/auth/reset-password-form.tsx +155 -0
- package/templates/modules/auth/nextjs/components/auth/sign-in-form.tsx +46 -32
- package/templates/modules/auth/nextjs/components/auth/sign-out-button.tsx +6 -5
- package/templates/modules/auth/nextjs/components/auth/sign-up-form.tsx +36 -30
- package/templates/modules/auth/nextjs/components/auth/social-buttons.tsx +73 -0
- package/templates/modules/auth/nextjs/config.json +8 -2
- package/templates/modules/auth/nextjs/lib/auth-schema.ts +27 -2
- package/templates/modules/auth/nextjs/lib/auth.ts.hbs +65 -0
- package/templates/modules/auth/nextjs/lib/email.ts +43 -0
- package/templates/modules/auth/nextjs/lib/session.ts +12 -0
- package/templates/modules/auth/tanstack/config.json +9 -3
- package/templates/modules/auth/tanstack/src/components/auth/change-password-form.tsx +123 -0
- package/templates/modules/auth/tanstack/src/components/auth/email-verification-card.tsx +89 -0
- package/templates/modules/auth/tanstack/src/components/auth/forgot-password-form.tsx +131 -0
- package/templates/modules/auth/tanstack/src/components/auth/reset-password-form.tsx +149 -0
- package/templates/modules/auth/tanstack/src/components/auth/sign-in-form.tsx +44 -35
- package/templates/modules/auth/tanstack/src/components/auth/sign-out-button.tsx +7 -6
- package/templates/modules/auth/tanstack/src/components/auth/sign-up-form.tsx +37 -34
- package/templates/modules/auth/tanstack/src/components/auth/social-buttons.tsx +71 -0
- package/templates/modules/auth/tanstack/src/lib/auth-functions.ts +7 -1
- package/templates/modules/auth/tanstack/src/lib/auth-schema.ts +27 -2
- package/templates/modules/auth/tanstack/src/lib/auth.ts.hbs +61 -1
- package/templates/modules/auth/tanstack/src/lib/email.ts +43 -0
- package/templates/modules/auth/tanstack/src/routeTree.gen.ts +61 -3
- package/templates/modules/auth/tanstack/src/routes/dashboard.tsx +40 -32
- package/templates/modules/auth/tanstack/src/routes/forgot-password.tsx +17 -0
- package/templates/modules/auth/tanstack/src/routes/reset-password.tsx +25 -0
- package/templates/modules/auth/tanstack/src/routes/sign-in.tsx +19 -12
- package/templates/modules/auth/tanstack/src/routes/sign-up.tsx +21 -14
- package/templates/modules/drizzle/express/src/lib/schema.ts.hbs +90 -0
- package/templates/modules/drizzle/nestjs/src/database/schema.ts.hbs +90 -0
- package/templates/modules/mongoose/nestjs/config.json +2 -1
- package/templates/modules/prisma/express/prisma/schema.prisma.hbs +92 -0
- package/templates/modules/prisma/nestjs/prisma/schema.prisma.hbs +91 -0
- package/templates/modules/rate-limit/express/src/middleware/rateLimit.ts +0 -8
- package/templates/modules/auth/express/src/controllers/authController.ts.hbs +0 -116
- package/templates/modules/auth/express/src/lib/jwt.ts.hbs +0 -27
- package/templates/modules/auth/express/src/middleware/requireAuth.ts.hbs +0 -25
- package/templates/modules/auth/express/src/routes/auth.ts.hbs +0 -27
- package/templates/modules/auth/express/src/services/userStore.ts.hbs +0 -117
- package/templates/modules/auth/nestjs/src/auth/auth.controller.ts.hbs +0 -61
- package/templates/modules/auth/nestjs/src/auth/auth.guard.ts +0 -36
- package/templates/modules/auth/nestjs/src/auth/auth.schemas.ts +0 -35
- package/templates/modules/auth/nestjs/src/auth/auth.service.ts +0 -70
- package/templates/modules/auth/nestjs/src/auth/jwt.ts +0 -27
- package/templates/modules/auth/nestjs/src/auth/users.service.ts.hbs +0 -144
- package/templates/modules/auth/nestjs/src/auth/zod-validation.pipe.ts +0 -16
- package/templates/modules/drizzle/express/src/lib/schema.ts +0 -12
- package/templates/modules/drizzle/nestjs/src/database/schema.ts +0 -12
- package/templates/modules/prisma/express/prisma/schema.prisma +0 -24
- package/templates/modules/prisma/nestjs/prisma/schema.prisma +0 -23
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createFileRoute } from "@tanstack/react-router";
|
|
2
|
+
|
|
3
|
+
import { ResetPasswordForm } from "~/components/auth/reset-password-form";
|
|
4
|
+
|
|
5
|
+
// The link in the reset email lands here with ?token=..., or with ?error=...
|
|
6
|
+
// when the token is invalid or has expired
|
|
7
|
+
export const Route = createFileRoute("/reset-password")({
|
|
8
|
+
validateSearch: (search: Record<string, unknown>) => ({
|
|
9
|
+
token: typeof search.token === "string" ? search.token : undefined,
|
|
10
|
+
error: typeof search.error === "string" ? search.error : undefined,
|
|
11
|
+
}),
|
|
12
|
+
component: ResetPasswordPage,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function ResetPasswordPage() {
|
|
16
|
+
const { token, error } = Route.useSearch();
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<main className="flex min-h-svh items-center justify-center bg-muted p-6 md:p-10">
|
|
20
|
+
<div className="w-full max-w-md">
|
|
21
|
+
<ResetPasswordForm token={error ? null : (token ?? null)} />
|
|
22
|
+
</div>
|
|
23
|
+
</main>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -1,37 +1,44 @@
|
|
|
1
1
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
|
2
2
|
|
|
3
3
|
import { SignInForm } from "~/components/auth/sign-in-form";
|
|
4
|
-
import {
|
|
4
|
+
import { Badge } from "~/components/ui/badge";
|
|
5
|
+
import { getSession, getSocialProviders } from "~/lib/auth-functions";
|
|
5
6
|
|
|
6
7
|
export const Route = createFileRoute("/sign-in")({
|
|
7
8
|
beforeLoad: async () => {
|
|
8
|
-
|
|
9
|
+
// Two independent server calls, made at the same time
|
|
10
|
+
const [session, providers] = await Promise.all([
|
|
11
|
+
getSession(),
|
|
12
|
+
getSocialProviders(),
|
|
13
|
+
]);
|
|
9
14
|
|
|
10
15
|
if (session) {
|
|
11
16
|
throw redirect({ to: "/dashboard" });
|
|
12
17
|
}
|
|
18
|
+
|
|
19
|
+
return { providers };
|
|
13
20
|
},
|
|
14
21
|
component: SignInPage,
|
|
15
22
|
});
|
|
16
23
|
|
|
17
24
|
function SignInPage() {
|
|
18
25
|
return (
|
|
19
|
-
<main className="min-h-
|
|
20
|
-
<div className="
|
|
21
|
-
<section className="hidden
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
</span>
|
|
25
|
-
<h1 className="text-4xl font-semibold tracking-tight text-slate-950">
|
|
26
|
+
<main className="flex min-h-svh items-center justify-center bg-muted p-6 md:p-10">
|
|
27
|
+
<div className="grid w-full max-w-5xl items-center gap-12 lg:grid-cols-[0.9fr_1.1fr]">
|
|
28
|
+
<section className="hidden flex-col gap-5 lg:flex">
|
|
29
|
+
<Badge variant="outline">Better Auth</Badge>
|
|
30
|
+
<h1 className="text-4xl font-semibold tracking-tight">
|
|
26
31
|
Welcome back.
|
|
27
32
|
</h1>
|
|
28
|
-
<p className="max-w-md text-base leading-7 text-
|
|
33
|
+
<p className="max-w-md text-base leading-7 text-muted-foreground">
|
|
29
34
|
This generated starter includes protected routes, session-aware page
|
|
30
35
|
guards, and reusable auth form primitives you can extend from here.
|
|
31
36
|
</p>
|
|
32
37
|
</section>
|
|
33
|
-
<SignInForm
|
|
38
|
+
<SignInForm
|
|
39
|
+
providers={Route.useRouteContext({ select: (ctx) => ctx.providers })}
|
|
40
|
+
/>
|
|
34
41
|
</div>
|
|
35
42
|
</main>
|
|
36
43
|
);
|
|
37
|
-
}
|
|
44
|
+
}
|
|
@@ -1,37 +1,44 @@
|
|
|
1
1
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
|
2
2
|
|
|
3
3
|
import { SignUpForm } from "~/components/auth/sign-up-form";
|
|
4
|
-
import {
|
|
4
|
+
import { Badge } from "~/components/ui/badge";
|
|
5
|
+
import { getSession, getSocialProviders } from "~/lib/auth-functions";
|
|
5
6
|
|
|
6
7
|
export const Route = createFileRoute("/sign-up")({
|
|
7
8
|
beforeLoad: async () => {
|
|
8
|
-
|
|
9
|
+
// Two independent server calls, made at the same time
|
|
10
|
+
const [session, providers] = await Promise.all([
|
|
11
|
+
getSession(),
|
|
12
|
+
getSocialProviders(),
|
|
13
|
+
]);
|
|
9
14
|
|
|
10
15
|
if (session) {
|
|
11
16
|
throw redirect({ to: "/dashboard" });
|
|
12
17
|
}
|
|
18
|
+
|
|
19
|
+
return { providers };
|
|
13
20
|
},
|
|
14
21
|
component: SignUpPage,
|
|
15
22
|
});
|
|
16
23
|
|
|
17
24
|
function SignUpPage() {
|
|
18
25
|
return (
|
|
19
|
-
<main className="min-h-
|
|
20
|
-
<div className="
|
|
21
|
-
<section className="hidden
|
|
22
|
-
<
|
|
23
|
-
|
|
24
|
-
</span>
|
|
25
|
-
<h1 className="text-4xl font-semibold tracking-tight text-slate-950">
|
|
26
|
+
<main className="flex min-h-svh items-center justify-center bg-muted p-6 md:p-10">
|
|
27
|
+
<div className="grid w-full max-w-5xl items-center gap-12 lg:grid-cols-[0.9fr_1.1fr]">
|
|
28
|
+
<section className="hidden flex-col gap-5 lg:flex">
|
|
29
|
+
<Badge variant="outline">Better Auth</Badge>
|
|
30
|
+
<h1 className="text-4xl font-semibold tracking-tight">
|
|
26
31
|
Create your account.
|
|
27
32
|
</h1>
|
|
28
|
-
<p className="max-w-md text-base leading-7 text-
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
<p className="max-w-md text-base leading-7 text-muted-foreground">
|
|
34
|
+
Sign up with email and password, or with GitHub or Google once
|
|
35
|
+
their keys are set. A verification link goes to your inbox.
|
|
31
36
|
</p>
|
|
32
37
|
</section>
|
|
33
|
-
<SignUpForm
|
|
38
|
+
<SignUpForm
|
|
39
|
+
providers={Route.useRouteContext({ select: (ctx) => ctx.providers })}
|
|
40
|
+
/>
|
|
34
41
|
</div>
|
|
35
42
|
</main>
|
|
36
43
|
);
|
|
37
|
-
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{{#if useAuth}}
|
|
2
|
+
import { boolean, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
|
|
3
|
+
|
|
4
|
+
export const user = pgTable("user", {
|
|
5
|
+
id: text("id").primaryKey(),
|
|
6
|
+
name: text("name").notNull(),
|
|
7
|
+
email: text("email").notNull().unique(),
|
|
8
|
+
emailVerified: boolean("email_verified").notNull().default(false),
|
|
9
|
+
image: text("image"),
|
|
10
|
+
createdAt: timestamp("created_at", { withTimezone: true })
|
|
11
|
+
.notNull()
|
|
12
|
+
.defaultNow(),
|
|
13
|
+
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
14
|
+
.notNull()
|
|
15
|
+
.defaultNow()
|
|
16
|
+
.$onUpdate(() => new Date()),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const session = pgTable("session", {
|
|
20
|
+
id: text("id").primaryKey(),
|
|
21
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
22
|
+
token: text("token").notNull().unique(),
|
|
23
|
+
createdAt: timestamp("created_at", { withTimezone: true })
|
|
24
|
+
.notNull()
|
|
25
|
+
.defaultNow(),
|
|
26
|
+
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
27
|
+
.notNull()
|
|
28
|
+
.defaultNow()
|
|
29
|
+
.$onUpdate(() => new Date()),
|
|
30
|
+
ipAddress: text("ip_address"),
|
|
31
|
+
userAgent: text("user_agent"),
|
|
32
|
+
userId: text("user_id")
|
|
33
|
+
.notNull()
|
|
34
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const account = pgTable("account", {
|
|
38
|
+
id: text("id").primaryKey(),
|
|
39
|
+
accountId: text("account_id").notNull(),
|
|
40
|
+
providerId: text("provider_id").notNull(),
|
|
41
|
+
userId: text("user_id")
|
|
42
|
+
.notNull()
|
|
43
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
44
|
+
accessToken: text("access_token"),
|
|
45
|
+
refreshToken: text("refresh_token"),
|
|
46
|
+
idToken: text("id_token"),
|
|
47
|
+
accessTokenExpiresAt: timestamp("access_token_expires_at", {
|
|
48
|
+
withTimezone: true,
|
|
49
|
+
}),
|
|
50
|
+
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", {
|
|
51
|
+
withTimezone: true,
|
|
52
|
+
}),
|
|
53
|
+
scope: text("scope"),
|
|
54
|
+
password: text("password"),
|
|
55
|
+
createdAt: timestamp("created_at", { withTimezone: true })
|
|
56
|
+
.notNull()
|
|
57
|
+
.defaultNow(),
|
|
58
|
+
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
59
|
+
.notNull()
|
|
60
|
+
.defaultNow()
|
|
61
|
+
.$onUpdate(() => new Date()),
|
|
62
|
+
}, (table) => [uniqueIndex("account_provider_account_id_unique").on(table.providerId, table.accountId)]);
|
|
63
|
+
|
|
64
|
+
export const verification = pgTable("verification", {
|
|
65
|
+
id: text("id").primaryKey(),
|
|
66
|
+
identifier: text("identifier").notNull(),
|
|
67
|
+
value: text("value").notNull(),
|
|
68
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
69
|
+
createdAt: timestamp("created_at", { withTimezone: true })
|
|
70
|
+
.notNull()
|
|
71
|
+
.defaultNow(),
|
|
72
|
+
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
73
|
+
.notNull()
|
|
74
|
+
.defaultNow()
|
|
75
|
+
.$onUpdate(() => new Date()),
|
|
76
|
+
});
|
|
77
|
+
{{else}}
|
|
78
|
+
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
79
|
+
|
|
80
|
+
export const users = pgTable("users", {
|
|
81
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
82
|
+
email: text("email").notNull().unique(),
|
|
83
|
+
name: text("name"),
|
|
84
|
+
password: text("password").notNull(),
|
|
85
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
|
86
|
+
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
87
|
+
.defaultNow()
|
|
88
|
+
.$onUpdate(() => new Date()),
|
|
89
|
+
});
|
|
90
|
+
{{/if}}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
{{#if useAuth}}
|
|
2
|
+
import { boolean, pgTable, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core";
|
|
3
|
+
|
|
4
|
+
export const user = pgTable("user", {
|
|
5
|
+
id: text("id").primaryKey(),
|
|
6
|
+
name: text("name").notNull(),
|
|
7
|
+
email: text("email").notNull().unique(),
|
|
8
|
+
emailVerified: boolean("email_verified").notNull().default(false),
|
|
9
|
+
image: text("image"),
|
|
10
|
+
createdAt: timestamp("created_at", { withTimezone: true })
|
|
11
|
+
.notNull()
|
|
12
|
+
.defaultNow(),
|
|
13
|
+
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
14
|
+
.notNull()
|
|
15
|
+
.defaultNow()
|
|
16
|
+
.$onUpdate(() => new Date()),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export const session = pgTable("session", {
|
|
20
|
+
id: text("id").primaryKey(),
|
|
21
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
22
|
+
token: text("token").notNull().unique(),
|
|
23
|
+
createdAt: timestamp("created_at", { withTimezone: true })
|
|
24
|
+
.notNull()
|
|
25
|
+
.defaultNow(),
|
|
26
|
+
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
27
|
+
.notNull()
|
|
28
|
+
.defaultNow()
|
|
29
|
+
.$onUpdate(() => new Date()),
|
|
30
|
+
ipAddress: text("ip_address"),
|
|
31
|
+
userAgent: text("user_agent"),
|
|
32
|
+
userId: text("user_id")
|
|
33
|
+
.notNull()
|
|
34
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export const account = pgTable("account", {
|
|
38
|
+
id: text("id").primaryKey(),
|
|
39
|
+
accountId: text("account_id").notNull(),
|
|
40
|
+
providerId: text("provider_id").notNull(),
|
|
41
|
+
userId: text("user_id")
|
|
42
|
+
.notNull()
|
|
43
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
44
|
+
accessToken: text("access_token"),
|
|
45
|
+
refreshToken: text("refresh_token"),
|
|
46
|
+
idToken: text("id_token"),
|
|
47
|
+
accessTokenExpiresAt: timestamp("access_token_expires_at", {
|
|
48
|
+
withTimezone: true,
|
|
49
|
+
}),
|
|
50
|
+
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", {
|
|
51
|
+
withTimezone: true,
|
|
52
|
+
}),
|
|
53
|
+
scope: text("scope"),
|
|
54
|
+
password: text("password"),
|
|
55
|
+
createdAt: timestamp("created_at", { withTimezone: true })
|
|
56
|
+
.notNull()
|
|
57
|
+
.defaultNow(),
|
|
58
|
+
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
59
|
+
.notNull()
|
|
60
|
+
.defaultNow()
|
|
61
|
+
.$onUpdate(() => new Date()),
|
|
62
|
+
}, (table) => [uniqueIndex("account_provider_account_id_unique").on(table.providerId, table.accountId)]);
|
|
63
|
+
|
|
64
|
+
export const verification = pgTable("verification", {
|
|
65
|
+
id: text("id").primaryKey(),
|
|
66
|
+
identifier: text("identifier").notNull(),
|
|
67
|
+
value: text("value").notNull(),
|
|
68
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
69
|
+
createdAt: timestamp("created_at", { withTimezone: true })
|
|
70
|
+
.notNull()
|
|
71
|
+
.defaultNow(),
|
|
72
|
+
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
73
|
+
.notNull()
|
|
74
|
+
.defaultNow()
|
|
75
|
+
.$onUpdate(() => new Date()),
|
|
76
|
+
});
|
|
77
|
+
{{else}}
|
|
78
|
+
import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
|
|
79
|
+
|
|
80
|
+
export const users = pgTable("users", {
|
|
81
|
+
id: uuid("id").primaryKey().defaultRandom(),
|
|
82
|
+
email: text("email").notNull().unique(),
|
|
83
|
+
name: text("name"),
|
|
84
|
+
password: text("password").notNull(),
|
|
85
|
+
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow(),
|
|
86
|
+
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
87
|
+
.defaultNow()
|
|
88
|
+
.$onUpdate(() => new Date()),
|
|
89
|
+
});
|
|
90
|
+
{{/if}}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Prisma schema file
|
|
2
|
+
// Learn more: https://pris.ly/d/prisma-schema
|
|
3
|
+
|
|
4
|
+
generator client {
|
|
5
|
+
provider = "prisma-client"
|
|
6
|
+
moduleFormat = "cjs"
|
|
7
|
+
output = "../src/generated/prisma"
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
datasource db {
|
|
11
|
+
provider = "postgresql"
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
{{#if useAuth}}
|
|
15
|
+
// Better Auth tables
|
|
16
|
+
model User {
|
|
17
|
+
id String @id
|
|
18
|
+
name String
|
|
19
|
+
email String
|
|
20
|
+
emailVerified Boolean @default(false)
|
|
21
|
+
image String?
|
|
22
|
+
createdAt DateTime @default(now())
|
|
23
|
+
updatedAt DateTime @updatedAt
|
|
24
|
+
sessions Session[]
|
|
25
|
+
accounts Account[]
|
|
26
|
+
|
|
27
|
+
@@unique([email])
|
|
28
|
+
@@map("user")
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
model Session {
|
|
32
|
+
id String @id
|
|
33
|
+
expiresAt DateTime
|
|
34
|
+
token String
|
|
35
|
+
createdAt DateTime @default(now())
|
|
36
|
+
updatedAt DateTime @updatedAt
|
|
37
|
+
ipAddress String?
|
|
38
|
+
userAgent String?
|
|
39
|
+
userId String
|
|
40
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
41
|
+
|
|
42
|
+
@@unique([token])
|
|
43
|
+
@@index([userId])
|
|
44
|
+
@@map("session")
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
model Account {
|
|
48
|
+
id String @id
|
|
49
|
+
accountId String
|
|
50
|
+
providerId String
|
|
51
|
+
userId String
|
|
52
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
53
|
+
accessToken String?
|
|
54
|
+
refreshToken String?
|
|
55
|
+
idToken String?
|
|
56
|
+
accessTokenExpiresAt DateTime?
|
|
57
|
+
refreshTokenExpiresAt DateTime?
|
|
58
|
+
scope String?
|
|
59
|
+
password String?
|
|
60
|
+
createdAt DateTime @default(now())
|
|
61
|
+
updatedAt DateTime @updatedAt
|
|
62
|
+
|
|
63
|
+
@@index([userId])
|
|
64
|
+
@@unique([providerId, accountId])
|
|
65
|
+
@@map("account")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
model Verification {
|
|
69
|
+
id String @id
|
|
70
|
+
identifier String
|
|
71
|
+
value String
|
|
72
|
+
expiresAt DateTime
|
|
73
|
+
createdAt DateTime @default(now())
|
|
74
|
+
updatedAt DateTime @updatedAt
|
|
75
|
+
|
|
76
|
+
@@index([identifier])
|
|
77
|
+
@@map("verification")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
{{else}}
|
|
81
|
+
// Sample User model
|
|
82
|
+
model User {
|
|
83
|
+
id String @id @default(uuid())
|
|
84
|
+
email String @unique
|
|
85
|
+
name String?
|
|
86
|
+
password String
|
|
87
|
+
createdAt DateTime @default(now())
|
|
88
|
+
updatedAt DateTime @updatedAt
|
|
89
|
+
|
|
90
|
+
@@map("users")
|
|
91
|
+
}
|
|
92
|
+
{{/if}}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Prisma schema file
|
|
2
|
+
// Learn more: https://pris.ly/d/prisma-schema
|
|
3
|
+
|
|
4
|
+
generator client {
|
|
5
|
+
provider = "prisma-client"
|
|
6
|
+
output = "../src/generated/prisma"
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
datasource db {
|
|
10
|
+
provider = "postgresql"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
{{#if useAuth}}
|
|
14
|
+
// Better Auth tables
|
|
15
|
+
model User {
|
|
16
|
+
id String @id
|
|
17
|
+
name String
|
|
18
|
+
email String
|
|
19
|
+
emailVerified Boolean @default(false)
|
|
20
|
+
image String?
|
|
21
|
+
createdAt DateTime @default(now())
|
|
22
|
+
updatedAt DateTime @updatedAt
|
|
23
|
+
sessions Session[]
|
|
24
|
+
accounts Account[]
|
|
25
|
+
|
|
26
|
+
@@unique([email])
|
|
27
|
+
@@map("user")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
model Session {
|
|
31
|
+
id String @id
|
|
32
|
+
expiresAt DateTime
|
|
33
|
+
token String
|
|
34
|
+
createdAt DateTime @default(now())
|
|
35
|
+
updatedAt DateTime @updatedAt
|
|
36
|
+
ipAddress String?
|
|
37
|
+
userAgent String?
|
|
38
|
+
userId String
|
|
39
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
40
|
+
|
|
41
|
+
@@unique([token])
|
|
42
|
+
@@index([userId])
|
|
43
|
+
@@map("session")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
model Account {
|
|
47
|
+
id String @id
|
|
48
|
+
accountId String
|
|
49
|
+
providerId String
|
|
50
|
+
userId String
|
|
51
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
52
|
+
accessToken String?
|
|
53
|
+
refreshToken String?
|
|
54
|
+
idToken String?
|
|
55
|
+
accessTokenExpiresAt DateTime?
|
|
56
|
+
refreshTokenExpiresAt DateTime?
|
|
57
|
+
scope String?
|
|
58
|
+
password String?
|
|
59
|
+
createdAt DateTime @default(now())
|
|
60
|
+
updatedAt DateTime @updatedAt
|
|
61
|
+
|
|
62
|
+
@@index([userId])
|
|
63
|
+
@@unique([providerId, accountId])
|
|
64
|
+
@@map("account")
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
model Verification {
|
|
68
|
+
id String @id
|
|
69
|
+
identifier String
|
|
70
|
+
value String
|
|
71
|
+
expiresAt DateTime
|
|
72
|
+
createdAt DateTime @default(now())
|
|
73
|
+
updatedAt DateTime @updatedAt
|
|
74
|
+
|
|
75
|
+
@@index([identifier])
|
|
76
|
+
@@map("verification")
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
{{else}}
|
|
80
|
+
// Sample User model
|
|
81
|
+
model User {
|
|
82
|
+
id String @id @default(uuid())
|
|
83
|
+
email String @unique
|
|
84
|
+
name String?
|
|
85
|
+
password String
|
|
86
|
+
createdAt DateTime @default(now())
|
|
87
|
+
updatedAt DateTime @updatedAt
|
|
88
|
+
|
|
89
|
+
@@map("users")
|
|
90
|
+
}
|
|
91
|
+
{{/if}}
|
|
@@ -13,11 +13,3 @@ export const apiLimiter = rateLimit({
|
|
|
13
13
|
legacyHeaders: false,
|
|
14
14
|
skip: (req) => req.path === "/health",
|
|
15
15
|
});
|
|
16
|
-
|
|
17
|
-
// Sign-up and login: 10 attempts per IP in 15 minutes, against password guessing
|
|
18
|
-
export const authLimiter = rateLimit({
|
|
19
|
-
windowMs: FIFTEEN_MINUTES,
|
|
20
|
-
limit: 10,
|
|
21
|
-
standardHeaders: "draft-8",
|
|
22
|
-
legacyHeaders: false,
|
|
23
|
-
});
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
import { Request, Response } from "express";
|
|
2
|
-
import bcrypt from "bcrypt";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
import { signToken } from "../lib/jwt";
|
|
5
|
-
import { AuthenticatedRequest } from "../middleware/requireAuth";
|
|
6
|
-
import {
|
|
7
|
-
createUser,
|
|
8
|
-
findUserByEmail,
|
|
9
|
-
isDuplicateEmailError,
|
|
10
|
-
toPublicUser,
|
|
11
|
-
} from "../services/userStore";
|
|
12
|
-
|
|
13
|
-
const SALT_ROUNDS = 10;
|
|
14
|
-
const MIN_PASSWORD_LENGTH = 8;
|
|
15
|
-
// bcrypt ignores bytes beyond 72, so longer passwords would be silently truncated
|
|
16
|
-
const MAX_PASSWORD_BYTES = 72;
|
|
17
|
-
|
|
18
|
-
// Emails are trimmed and lowercased so sign-in is case-insensitive
|
|
19
|
-
const emailSchema = z
|
|
20
|
-
.string("Email and password are required")
|
|
21
|
-
.trim()
|
|
22
|
-
.toLowerCase()
|
|
23
|
-
.pipe(z.email("Email must be a valid email address"));
|
|
24
|
-
|
|
25
|
-
const registerSchema = z.object({
|
|
26
|
-
email: emailSchema,
|
|
27
|
-
password: z
|
|
28
|
-
.string("Email and password are required")
|
|
29
|
-
.min(
|
|
30
|
-
MIN_PASSWORD_LENGTH,
|
|
31
|
-
`Password must be at least ${MIN_PASSWORD_LENGTH} characters`,
|
|
32
|
-
)
|
|
33
|
-
.refine(
|
|
34
|
-
(password) => Buffer.byteLength(password, "utf8") <= MAX_PASSWORD_BYTES,
|
|
35
|
-
`Password must be at most ${MAX_PASSWORD_BYTES} bytes`,
|
|
36
|
-
),
|
|
37
|
-
name: z.string("Name must be a string").nullish(),
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
const loginSchema = z.object({
|
|
41
|
-
email: emailSchema,
|
|
42
|
-
password: z.string("Email and password are required").min(1),
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
// Sends a 400 with the first validation message and returns null when the body is invalid
|
|
46
|
-
function parseBody<T>(schema: z.ZodType<T>, req: Request, res: Response): T | null {
|
|
47
|
-
// Express 5 leaves req.body undefined when the request has no body
|
|
48
|
-
const result = schema.safeParse(req.body ?? {});
|
|
49
|
-
if (result.success) return result.data;
|
|
50
|
-
|
|
51
|
-
res.status(400).json({ error: result.error.issues[0].message });
|
|
52
|
-
return null;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export async function register(req: Request, res: Response): Promise<void> {
|
|
56
|
-
const body = parseBody(registerSchema, req, res);
|
|
57
|
-
if (!body) return;
|
|
58
|
-
const { email, password, name = null } = body;
|
|
59
|
-
|
|
60
|
-
const existingUser = await findUserByEmail(email);
|
|
61
|
-
if (existingUser) {
|
|
62
|
-
res.status(409).json({ error: "User already exists" });
|
|
63
|
-
return;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const hashedPassword = await bcrypt.hash(password, SALT_ROUNDS);
|
|
67
|
-
|
|
68
|
-
let user;
|
|
69
|
-
try {
|
|
70
|
-
user = await createUser({ email, name, password: hashedPassword });
|
|
71
|
-
} catch (error) {
|
|
72
|
-
// A concurrent request registered the same email after the check above
|
|
73
|
-
if (isDuplicateEmailError(error)) {
|
|
74
|
-
res.status(409).json({ error: "User already exists" });
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
throw error;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const token = signToken({ sub: user.id, email: user.email, name: user.name });
|
|
81
|
-
|
|
82
|
-
res.status(201).json({ user: toPublicUser(user), token });
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export async function login(req: Request, res: Response): Promise<void> {
|
|
86
|
-
const body = parseBody(loginSchema, req, res);
|
|
87
|
-
if (!body) return;
|
|
88
|
-
const { email, password } = body;
|
|
89
|
-
|
|
90
|
-
const user = await findUserByEmail(email);
|
|
91
|
-
|
|
92
|
-
if (!user) {
|
|
93
|
-
res.status(401).json({ error: "Invalid credentials" });
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const isValidPassword = await bcrypt.compare(password, user.password);
|
|
98
|
-
|
|
99
|
-
if (!isValidPassword) {
|
|
100
|
-
res.status(401).json({ error: "Invalid credentials" });
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const token = signToken({ sub: user.id, email: user.email, name: user.name });
|
|
105
|
-
|
|
106
|
-
res.json({ user: toPublicUser(user), token });
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function me(req: AuthenticatedRequest, res: Response): void {
|
|
110
|
-
if (!req.user) {
|
|
111
|
-
res.status(401).json({ error: "Unauthorized" });
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
res.json({ user: req.user });
|
|
116
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import jwt, { type SignOptions } from "jsonwebtoken";
|
|
2
|
-
|
|
3
|
-
export interface AuthTokenPayload {
|
|
4
|
-
sub: string;
|
|
5
|
-
email: string;
|
|
6
|
-
name?: string | null;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
const { JWT_SECRET, JWT_EXPIRES_IN } = process.env;
|
|
10
|
-
|
|
11
|
-
function ensureSecret(): string {
|
|
12
|
-
if (!JWT_SECRET) {
|
|
13
|
-
throw new Error("JWT_SECRET is not set");
|
|
14
|
-
}
|
|
15
|
-
return JWT_SECRET;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export function signToken(payload: AuthTokenPayload): string {
|
|
19
|
-
const secret = ensureSecret();
|
|
20
|
-
const expiresIn = JWT_EXPIRES_IN || "1h";
|
|
21
|
-
return jwt.sign(payload, secret, { expiresIn } as SignOptions);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function verifyToken(token: string): AuthTokenPayload {
|
|
25
|
-
const secret = ensureSecret();
|
|
26
|
-
return jwt.verify(token, secret) as AuthTokenPayload;
|
|
27
|
-
}
|