@alfredmouelle/create-stack 0.1.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 (206) hide show
  1. package/README.md +56 -0
  2. package/_stack/apps/next-base/.dockerignore +10 -0
  3. package/_stack/apps/next-base/Dockerfile +34 -0
  4. package/_stack/apps/next-base/README.md +32 -0
  5. package/_stack/apps/next-base/components.json +25 -0
  6. package/_stack/apps/next-base/drizzle.config.ts +16 -0
  7. package/_stack/apps/next-base/next.config.ts +8 -0
  8. package/_stack/apps/next-base/package.json +70 -0
  9. package/_stack/apps/next-base/postcss.config.mjs +7 -0
  10. package/_stack/apps/next-base/src/app/api/auth/[...all]/route.ts +4 -0
  11. package/_stack/apps/next-base/src/app/api/trpc/[trpc]/route.ts +23 -0
  12. package/_stack/apps/next-base/src/app/auth/_components/forgot-password-form.tsx +92 -0
  13. package/_stack/apps/next-base/src/app/auth/_components/reset-password-form.tsx +105 -0
  14. package/_stack/apps/next-base/src/app/auth/_components/sign-in-form.tsx +126 -0
  15. package/_stack/apps/next-base/src/app/auth/_components/sign-up-form.tsx +139 -0
  16. package/_stack/apps/next-base/src/app/auth/_components/verify-email-actions.tsx +45 -0
  17. package/_stack/apps/next-base/src/app/auth/forgot-password/page.tsx +19 -0
  18. package/_stack/apps/next-base/src/app/auth/layout.tsx +9 -0
  19. package/_stack/apps/next-base/src/app/auth/reset-password/page.tsx +26 -0
  20. package/_stack/apps/next-base/src/app/auth/sign-in/page.tsx +27 -0
  21. package/_stack/apps/next-base/src/app/auth/sign-up/page.tsx +27 -0
  22. package/_stack/apps/next-base/src/app/auth/verify-email/page.tsx +30 -0
  23. package/_stack/apps/next-base/src/app/dashboard/page.tsx +12 -0
  24. package/_stack/apps/next-base/src/app/globals.css +171 -0
  25. package/_stack/apps/next-base/src/app/layout.tsx +23 -0
  26. package/_stack/apps/next-base/src/app/page.tsx +15 -0
  27. package/_stack/apps/next-base/src/components/data-table.tsx +77 -0
  28. package/_stack/apps/next-base/src/components/infinite-data-table.tsx +102 -0
  29. package/_stack/apps/next-base/src/components/sortable-header.tsx +37 -0
  30. package/_stack/apps/next-base/src/components/theme-provider.tsx +8 -0
  31. package/_stack/apps/next-base/src/components/theme-toggle.tsx +37 -0
  32. package/_stack/apps/next-base/src/components/ui/button.tsx +64 -0
  33. package/_stack/apps/next-base/src/components/ui/calendar.tsx +185 -0
  34. package/_stack/apps/next-base/src/components/ui/card.tsx +84 -0
  35. package/_stack/apps/next-base/src/components/ui/date-picker.tsx +85 -0
  36. package/_stack/apps/next-base/src/components/ui/date-range-picker.tsx +138 -0
  37. package/_stack/apps/next-base/src/components/ui/dropdown-menu.tsx +246 -0
  38. package/_stack/apps/next-base/src/components/ui/form.tsx +149 -0
  39. package/_stack/apps/next-base/src/components/ui/input-group.tsx +97 -0
  40. package/_stack/apps/next-base/src/components/ui/input.tsx +18 -0
  41. package/_stack/apps/next-base/src/components/ui/label.tsx +18 -0
  42. package/_stack/apps/next-base/src/components/ui/popover.tsx +76 -0
  43. package/_stack/apps/next-base/src/components/ui/skeleton.tsx +13 -0
  44. package/_stack/apps/next-base/src/components/ui/spinner.tsx +8 -0
  45. package/_stack/apps/next-base/src/components/ui/table.tsx +87 -0
  46. package/_stack/apps/next-base/src/emails/components/components.tsx +199 -0
  47. package/_stack/apps/next-base/src/emails/components/context.tsx +18 -0
  48. package/_stack/apps/next-base/src/emails/components/index.ts +23 -0
  49. package/_stack/apps/next-base/src/emails/components/theme.ts +65 -0
  50. package/_stack/apps/next-base/src/emails/reset-password.tsx +16 -0
  51. package/_stack/apps/next-base/src/emails/verify-email.tsx +15 -0
  52. package/_stack/apps/next-base/src/env.ts +41 -0
  53. package/_stack/apps/next-base/src/features/auth/auth-card.tsx +30 -0
  54. package/_stack/apps/next-base/src/features/auth/form-alert.tsx +27 -0
  55. package/_stack/apps/next-base/src/features/auth/google-button.tsx +66 -0
  56. package/_stack/apps/next-base/src/features/auth/schemas.ts +35 -0
  57. package/_stack/apps/next-base/src/lib/date.ts +4 -0
  58. package/_stack/apps/next-base/src/lib/utils.ts +6 -0
  59. package/_stack/apps/next-base/src/server/api/root.ts +10 -0
  60. package/_stack/apps/next-base/src/server/api/routers/health.router.ts +8 -0
  61. package/_stack/apps/next-base/src/server/api/trpc.ts +56 -0
  62. package/_stack/apps/next-base/src/server/auth/guards.ts +10 -0
  63. package/_stack/apps/next-base/src/server/better-auth/client.ts +9 -0
  64. package/_stack/apps/next-base/src/server/better-auth/config.ts +60 -0
  65. package/_stack/apps/next-base/src/server/better-auth/emails.tsx +25 -0
  66. package/_stack/apps/next-base/src/server/better-auth/index.ts +1 -0
  67. package/_stack/apps/next-base/src/server/better-auth/server.ts +14 -0
  68. package/_stack/apps/next-base/src/server/db/index.ts +6 -0
  69. package/_stack/apps/next-base/src/server/db/keyset.ts +63 -0
  70. package/_stack/apps/next-base/src/server/db/schemas/auth.schema.ts +71 -0
  71. package/_stack/apps/next-base/src/server/db/schemas/index.ts +2 -0
  72. package/_stack/apps/next-base/src/server/db/seed.ts +27 -0
  73. package/_stack/apps/next-base/src/server/email/adapters/resend/config.ts +7 -0
  74. package/_stack/apps/next-base/src/server/email/adapters/resend/index.ts +75 -0
  75. package/_stack/apps/next-base/src/server/email/core/address.ts +21 -0
  76. package/_stack/apps/next-base/src/server/email/core/port.ts +89 -0
  77. package/_stack/apps/next-base/src/server/email/core/render.ts +16 -0
  78. package/_stack/apps/next-base/src/server/email/factory.ts +47 -0
  79. package/_stack/apps/next-base/src/server/email/index.ts +36 -0
  80. package/_stack/apps/next-base/src/trpc/query-client.ts +19 -0
  81. package/_stack/apps/next-base/src/trpc/react.tsx +62 -0
  82. package/_stack/apps/next-base/src/trpc/server.ts +23 -0
  83. package/_stack/apps/next-base/tsconfig.json +37 -0
  84. package/_stack/apps/tanstack-base/.dockerignore +13 -0
  85. package/_stack/apps/tanstack-base/Dockerfile +28 -0
  86. package/_stack/apps/tanstack-base/README.md +31 -0
  87. package/_stack/apps/tanstack-base/components.json +25 -0
  88. package/_stack/apps/tanstack-base/drizzle.config.ts +16 -0
  89. package/_stack/apps/tanstack-base/package.json +85 -0
  90. package/_stack/apps/tanstack-base/public/favicon.ico +0 -0
  91. package/_stack/apps/tanstack-base/public/logo192.png +0 -0
  92. package/_stack/apps/tanstack-base/public/logo512.png +0 -0
  93. package/_stack/apps/tanstack-base/public/manifest.json +25 -0
  94. package/_stack/apps/tanstack-base/public/robots.txt +3 -0
  95. package/_stack/apps/tanstack-base/src/components/data-table.tsx +77 -0
  96. package/_stack/apps/tanstack-base/src/components/form/field-error.tsx +18 -0
  97. package/_stack/apps/tanstack-base/src/components/form/text-field.tsx +47 -0
  98. package/_stack/apps/tanstack-base/src/components/infinite-data-table.tsx +102 -0
  99. package/_stack/apps/tanstack-base/src/components/sortable-header.tsx +37 -0
  100. package/_stack/apps/tanstack-base/src/components/theme-provider.tsx +69 -0
  101. package/_stack/apps/tanstack-base/src/components/theme-toggle.tsx +35 -0
  102. package/_stack/apps/tanstack-base/src/components/ui/button.tsx +64 -0
  103. package/_stack/apps/tanstack-base/src/components/ui/calendar.tsx +185 -0
  104. package/_stack/apps/tanstack-base/src/components/ui/card.tsx +84 -0
  105. package/_stack/apps/tanstack-base/src/components/ui/date-picker.tsx +83 -0
  106. package/_stack/apps/tanstack-base/src/components/ui/date-range-picker.tsx +136 -0
  107. package/_stack/apps/tanstack-base/src/components/ui/dropdown-menu.tsx +246 -0
  108. package/_stack/apps/tanstack-base/src/components/ui/input-group.tsx +97 -0
  109. package/_stack/apps/tanstack-base/src/components/ui/input.tsx +18 -0
  110. package/_stack/apps/tanstack-base/src/components/ui/label.tsx +18 -0
  111. package/_stack/apps/tanstack-base/src/components/ui/popover.tsx +74 -0
  112. package/_stack/apps/tanstack-base/src/components/ui/skeleton.tsx +13 -0
  113. package/_stack/apps/tanstack-base/src/components/ui/spinner.tsx +8 -0
  114. package/_stack/apps/tanstack-base/src/components/ui/table.tsx +87 -0
  115. package/_stack/apps/tanstack-base/src/emails/components/components.tsx +199 -0
  116. package/_stack/apps/tanstack-base/src/emails/components/context.tsx +18 -0
  117. package/_stack/apps/tanstack-base/src/emails/components/index.ts +23 -0
  118. package/_stack/apps/tanstack-base/src/emails/components/theme.ts +65 -0
  119. package/_stack/apps/tanstack-base/src/emails/reset-password.tsx +16 -0
  120. package/_stack/apps/tanstack-base/src/emails/verify-email.tsx +15 -0
  121. package/_stack/apps/tanstack-base/src/env.ts +41 -0
  122. package/_stack/apps/tanstack-base/src/features/auth/auth-card.tsx +30 -0
  123. package/_stack/apps/tanstack-base/src/features/auth/form-alert.tsx +27 -0
  124. package/_stack/apps/tanstack-base/src/features/auth/google-button.tsx +64 -0
  125. package/_stack/apps/tanstack-base/src/features/auth/schemas.ts +35 -0
  126. package/_stack/apps/tanstack-base/src/lib/date.ts +4 -0
  127. package/_stack/apps/tanstack-base/src/lib/utils.ts +6 -0
  128. package/_stack/apps/tanstack-base/src/router.tsx +40 -0
  129. package/_stack/apps/tanstack-base/src/routes/__root.tsx +73 -0
  130. package/_stack/apps/tanstack-base/src/routes/_authed/dashboard.tsx +12 -0
  131. package/_stack/apps/tanstack-base/src/routes/_authed.tsx +21 -0
  132. package/_stack/apps/tanstack-base/src/routes/api/auth/$.ts +14 -0
  133. package/_stack/apps/tanstack-base/src/routes/api.trpc.$.tsx +31 -0
  134. package/_stack/apps/tanstack-base/src/routes/auth/forgot-password.tsx +89 -0
  135. package/_stack/apps/tanstack-base/src/routes/auth/reset-password.tsx +111 -0
  136. package/_stack/apps/tanstack-base/src/routes/auth/sign-in.tsx +117 -0
  137. package/_stack/apps/tanstack-base/src/routes/auth/sign-up.tsx +119 -0
  138. package/_stack/apps/tanstack-base/src/routes/auth/verify-email.tsx +72 -0
  139. package/_stack/apps/tanstack-base/src/routes/auth.tsx +22 -0
  140. package/_stack/apps/tanstack-base/src/routes/index.tsx +18 -0
  141. package/_stack/apps/tanstack-base/src/server/api/root.ts +10 -0
  142. package/_stack/apps/tanstack-base/src/server/api/routers/health.router.ts +8 -0
  143. package/_stack/apps/tanstack-base/src/server/api/trpc.ts +61 -0
  144. package/_stack/apps/tanstack-base/src/server/better-auth/client.ts +9 -0
  145. package/_stack/apps/tanstack-base/src/server/better-auth/config.ts +68 -0
  146. package/_stack/apps/tanstack-base/src/server/better-auth/emails.tsx +25 -0
  147. package/_stack/apps/tanstack-base/src/server/better-auth/index.ts +1 -0
  148. package/_stack/apps/tanstack-base/src/server/better-auth/session.ts +9 -0
  149. package/_stack/apps/tanstack-base/src/server/db/index.ts +6 -0
  150. package/_stack/apps/tanstack-base/src/server/db/keyset.ts +63 -0
  151. package/_stack/apps/tanstack-base/src/server/db/schemas/auth.schema.ts +71 -0
  152. package/_stack/apps/tanstack-base/src/server/db/schemas/index.ts +2 -0
  153. package/_stack/apps/tanstack-base/src/server/db/seed.ts +27 -0
  154. package/_stack/apps/tanstack-base/src/server/email/adapters/resend/config.ts +7 -0
  155. package/_stack/apps/tanstack-base/src/server/email/adapters/resend/index.ts +75 -0
  156. package/_stack/apps/tanstack-base/src/server/email/core/address.ts +21 -0
  157. package/_stack/apps/tanstack-base/src/server/email/core/port.ts +89 -0
  158. package/_stack/apps/tanstack-base/src/server/email/core/render.ts +16 -0
  159. package/_stack/apps/tanstack-base/src/server/email/factory.ts +47 -0
  160. package/_stack/apps/tanstack-base/src/server/email/index.ts +36 -0
  161. package/_stack/apps/tanstack-base/src/styles.css +171 -0
  162. package/_stack/apps/tanstack-base/src/trpc/devtools.tsx +6 -0
  163. package/_stack/apps/tanstack-base/src/trpc/query-client.ts +19 -0
  164. package/_stack/apps/tanstack-base/src/trpc/react.tsx +49 -0
  165. package/_stack/apps/tanstack-base/src/trpc/server.ts +11 -0
  166. package/_stack/apps/tanstack-base/tsconfig.json +27 -0
  167. package/_stack/apps/tanstack-base/tsr.config.json +3 -0
  168. package/_stack/apps/tanstack-base/vite.config.ts +15 -0
  169. package/_stack/packages/analytics/capability.json +26 -0
  170. package/_stack/packages/cache/capability.json +21 -0
  171. package/_stack/packages/error-tracking/capability.json +21 -0
  172. package/_stack/packages/jobs/capability.json +26 -0
  173. package/_stack/packages/logger/capability.json +21 -0
  174. package/_stack/packages/mailer/capability.json +28 -0
  175. package/_stack/packages/mailer/package.json +37 -0
  176. package/_stack/packages/mailer/src/adapters/brevo/config.ts +7 -0
  177. package/_stack/packages/mailer/src/adapters/brevo/index.ts +90 -0
  178. package/_stack/packages/mailer/src/adapters/resend/config.ts +7 -0
  179. package/_stack/packages/mailer/src/adapters/resend/index.ts +75 -0
  180. package/_stack/packages/mailer/src/adapters/ses/config.ts +13 -0
  181. package/_stack/packages/mailer/src/adapters/ses/index.ts +103 -0
  182. package/_stack/packages/storage/capability.json +32 -0
  183. package/_stack/patterns/README.md +58 -0
  184. package/_stack/patterns/_baseline/README-author.md +10 -0
  185. package/_stack/patterns/_baseline/biome.jsonc +119 -0
  186. package/_stack/patterns/_baseline/env.ts +31 -0
  187. package/_stack/patterns/_baseline/tsconfig.json +27 -0
  188. package/_stack/patterns/better-auth/pattern.json +73 -0
  189. package/_stack/patterns/better-auth-next/pattern.json +76 -0
  190. package/_stack/patterns/data-table/pattern.json +43 -0
  191. package/_stack/patterns/drizzle/pattern.json +61 -0
  192. package/_stack/patterns/trpc/pattern.json +61 -0
  193. package/_stack/patterns/trpc-next/pattern.json +64 -0
  194. package/index.mjs +216 -0
  195. package/lib/build.mjs +64 -0
  196. package/lib/env.mjs +56 -0
  197. package/lib/identity.mjs +33 -0
  198. package/lib/mailer.mjs +95 -0
  199. package/lib/manifests.mjs +61 -0
  200. package/lib/scaffold.mjs +49 -0
  201. package/lib/strip.mjs +132 -0
  202. package/lib/util.mjs +82 -0
  203. package/package.json +51 -0
  204. package/templates/next/layout.no-trpc.tsx +22 -0
  205. package/templates/tanstack/__root.no-trpc.tsx +63 -0
  206. package/templates/tanstack/router.no-trpc.tsx +24 -0
@@ -0,0 +1,72 @@
1
+ import { createFileRoute, Link } from '@tanstack/react-router'
2
+ import { useState } from 'react'
3
+ import { Button } from '~/components/ui/button'
4
+ import { Spinner } from '~/components/ui/spinner'
5
+ import { AuthCard } from '~/features/auth/auth-card'
6
+ import { FormAlert } from '~/features/auth/form-alert'
7
+ import { authClient } from '~/server/better-auth/client'
8
+
9
+ interface VerifyEmailSearch {
10
+ email?: string
11
+ }
12
+
13
+ export const Route = createFileRoute('/auth/verify-email')({
14
+ validateSearch: (search: Record<string, unknown>): VerifyEmailSearch => ({
15
+ email: typeof search.email === 'string' ? search.email : undefined,
16
+ }),
17
+ component: VerifyEmail,
18
+ })
19
+
20
+ function VerifyEmail() {
21
+ const { email } = Route.useSearch()
22
+ const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle')
23
+
24
+ const resend = async () => {
25
+ if (!email) return
26
+ setStatus('sending')
27
+ const { error } = await authClient.sendVerificationEmail({ email, callbackURL: '/' })
28
+ setStatus(error ? 'error' : 'sent')
29
+ }
30
+
31
+ return (
32
+ <AuthCard
33
+ description={
34
+ <>
35
+ A confirmation link was sent{email ? ` to ${email}` : ''}. Click it to activate your
36
+ account.
37
+ </>
38
+ }
39
+ footer={
40
+ <Link className="text-foreground hover:underline" to="/auth/sign-in">
41
+ Back to sign in
42
+ </Link>
43
+ }
44
+ title="Confirm your email"
45
+ >
46
+ <div className="grid gap-4">
47
+ {status === 'sent' ? (
48
+ <FormAlert variant="success">
49
+ Request sent. If your address isn't confirmed yet, you'll get an email — check your spam
50
+ folder.
51
+ </FormAlert>
52
+ ) : null}
53
+ {status === 'error' ? <FormAlert>Could not send. Try again shortly.</FormAlert> : null}
54
+
55
+ <p className="text-muted-foreground text-sm">
56
+ Didn't get anything? Check your spam folder or resend the link.
57
+ </p>
58
+
59
+ <Button
60
+ className="w-full cursor-pointer"
61
+ disabled={!email || status === 'sending'}
62
+ onClick={resend}
63
+ type="button"
64
+ variant="outline"
65
+ >
66
+ {status === 'sending' ? <Spinner /> : null}
67
+ {status === 'sending' ? 'Sending…' : 'Resend link'}
68
+ </Button>
69
+ </div>
70
+ </AuthCard>
71
+ )
72
+ }
@@ -0,0 +1,22 @@
1
+ import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
2
+ import { getServerSession } from '~/server/better-auth/session'
3
+
4
+ export const Route = createFileRoute('/auth')({
5
+ beforeLoad: async () => {
6
+ const session = await getServerSession()
7
+ if (session) {
8
+ throw redirect({ to: '/' })
9
+ }
10
+ },
11
+ component: AuthLayout,
12
+ })
13
+
14
+ function AuthLayout() {
15
+ return (
16
+ <div className="flex min-h-svh flex-col items-center justify-center gap-8 bg-gradient-to-b from-background to-muted/50 px-4 py-10">
17
+ <div className="w-full max-w-sm">
18
+ <Outlet />
19
+ </div>
20
+ </div>
21
+ )
22
+ }
@@ -0,0 +1,18 @@
1
+ import { createFileRoute } from '@tanstack/react-router'
2
+ import { ThemeToggle } from '~/components/theme-toggle'
3
+
4
+ export const Route = createFileRoute('/')({ component: Home })
5
+
6
+ function Home() {
7
+ return (
8
+ <div className="p-8">
9
+ <div className="flex items-center justify-between">
10
+ <h1 className="font-bold text-4xl">Base</h1>
11
+ <ThemeToggle />
12
+ </div>
13
+ <p className="mt-4 text-lg">
14
+ TanStack Start base. Add tools with <code>add-capability</code>.
15
+ </p>
16
+ </div>
17
+ )
18
+ }
@@ -0,0 +1,10 @@
1
+ import { healthRouter } from './routers/health.router'
2
+ import { createCallerFactory, createTRPCRouter } from './trpc'
3
+
4
+ export const appRouter = createTRPCRouter({
5
+ health: healthRouter,
6
+ })
7
+
8
+ export type AppRouter = typeof appRouter
9
+
10
+ export const createCaller = createCallerFactory(appRouter)
@@ -0,0 +1,8 @@
1
+ import { createTRPCRouter, publicProcedure } from '../trpc'
2
+
3
+ export const healthRouter = createTRPCRouter({
4
+ ping: publicProcedure.query(() => ({
5
+ ok: true,
6
+ time: new Date().toISOString(),
7
+ })),
8
+ })
@@ -0,0 +1,61 @@
1
+ import { initTRPC, TRPCError } from '@trpc/server'
2
+ import superjson from 'superjson'
3
+ import { flatten, ValiError } from 'valibot'
4
+ import { auth } from '~/server/better-auth'
5
+ import { db } from '~/server/db'
6
+
7
+ export const createTRPCContext = async (opts: { headers: Headers }) => {
8
+ const session = await auth.api.getSession({ headers: opts.headers })
9
+ return { db, session, ...opts }
10
+ }
11
+
12
+ const t = initTRPC.context<typeof createTRPCContext>().create({
13
+ transformer: superjson,
14
+ errorFormatter({ shape, error }) {
15
+ return {
16
+ ...shape,
17
+ data: {
18
+ ...shape.data,
19
+ validationError: error.cause instanceof ValiError ? flatten(error.cause.issues) : null,
20
+ },
21
+ }
22
+ },
23
+ })
24
+
25
+ export const createCallerFactory = t.createCallerFactory
26
+
27
+ export const createTRPCRouter = t.router
28
+
29
+ const isTest = process.env.VITEST === 'true'
30
+
31
+ const timingMiddleware = t.middleware(async ({ next, path }) => {
32
+ const start = Date.now()
33
+
34
+ if (t._config.isDev && !isTest) {
35
+ const waitMs = Math.floor(Math.random() * 400) + 100
36
+ await new Promise((resolve) => setTimeout(resolve, waitMs))
37
+ }
38
+
39
+ const result = await next()
40
+
41
+ if (!isTest) {
42
+ // biome-ignore lint/suspicious/noConsole: dev timing instrumentation
43
+ console.log(`[TRPC] ${path} took ${Date.now() - start}ms to execute`)
44
+ }
45
+
46
+ return result
47
+ })
48
+
49
+ export const publicProcedure = t.procedure.use(timingMiddleware)
50
+
51
+ export const protectedProcedure = t.procedure.use(timingMiddleware).use(({ ctx, next }) => {
52
+ if (!ctx.session?.user) {
53
+ throw new TRPCError({ code: 'UNAUTHORIZED' })
54
+ }
55
+
56
+ return next({
57
+ ctx: {
58
+ session: { ...ctx.session, user: ctx.session.user },
59
+ },
60
+ })
61
+ })
@@ -0,0 +1,9 @@
1
+ import { inferAdditionalFields } from 'better-auth/client/plugins'
2
+ import { createAuthClient } from 'better-auth/react'
3
+ import type { auth } from './config'
4
+
5
+ export const authClient = createAuthClient({
6
+ plugins: [inferAdditionalFields<typeof auth>()],
7
+ })
8
+
9
+ export type Session = typeof authClient.$Infer.Session
@@ -0,0 +1,68 @@
1
+ import { betterAuth } from 'better-auth'
2
+ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
3
+ import { tanstackStartCookies } from 'better-auth/tanstack-start'
4
+ import { env } from '~/env'
5
+ import { db } from '~/server/db'
6
+ import { sendPasswordResetEmail, sendVerificationEmail } from './emails'
7
+
8
+ const socialProviders =
9
+ env.BETTER_AUTH_GOOGLE_CLIENT_ID && env.BETTER_AUTH_GOOGLE_CLIENT_SECRET
10
+ ? {
11
+ google: {
12
+ clientId: env.BETTER_AUTH_GOOGLE_CLIENT_ID,
13
+ clientSecret: env.BETTER_AUTH_GOOGLE_CLIENT_SECRET,
14
+ },
15
+ }
16
+ : undefined
17
+
18
+ export const auth = betterAuth({
19
+ database: drizzleAdapter(db, {
20
+ provider: 'pg',
21
+ }),
22
+ emailAndPassword: {
23
+ enabled: true,
24
+ minPasswordLength: 8,
25
+ requireEmailVerification: true,
26
+ sendResetPassword: async ({ user, url }) => {
27
+ await sendPasswordResetEmail({
28
+ to: { email: user.email, name: user.name },
29
+ url,
30
+ })
31
+ },
32
+ },
33
+ emailVerification: {
34
+ sendOnSignUp: true,
35
+ autoSignInAfterVerification: true,
36
+ expiresIn: 60 * 60,
37
+ sendVerificationEmail: async ({ user, url }) => {
38
+ await sendVerificationEmail({
39
+ to: { email: user.email, name: user.name },
40
+ url,
41
+ })
42
+ },
43
+ },
44
+ socialProviders,
45
+ user: {
46
+ // Extend the user with extra columns here (mirror them in auth.schema.ts):
47
+ // additionalFields: {
48
+ // role: { type: 'string', defaultValue: 'user', input: false },
49
+ // },
50
+ additionalFields: {},
51
+ },
52
+ trustedOrigins: [env.BETTER_AUTH_URL],
53
+ rateLimit: {
54
+ enabled: env.NODE_ENV === 'production',
55
+ window: 60,
56
+ max: 100,
57
+ customRules: {
58
+ '/sign-up/email': { window: 60, max: 5 },
59
+ '/sign-in/email': { window: 60, max: 10 },
60
+ '/forget-password': { window: 60, max: 3 },
61
+ '/request-password-reset': { window: 60, max: 3 },
62
+ '/send-verification-email': { window: 60, max: 3 },
63
+ },
64
+ },
65
+ plugins: [tanstackStartCookies()],
66
+ })
67
+
68
+ export type Session = typeof auth.$Infer.Session
@@ -0,0 +1,25 @@
1
+ import { ResetPasswordEmail } from '~/emails/reset-password'
2
+ import { VerifyEmail } from '~/emails/verify-email'
3
+ import { type EmailRecipient, sendEmail } from '~/server/email'
4
+
5
+ export const sendVerificationEmail = async (params: {
6
+ to: EmailRecipient
7
+ url: string
8
+ }): Promise<void> => {
9
+ await sendEmail({
10
+ to: params.to,
11
+ subject: 'Confirm your email address',
12
+ template: <VerifyEmail name={params.to.name} url={params.url} />,
13
+ })
14
+ }
15
+
16
+ export const sendPasswordResetEmail = async (params: {
17
+ to: EmailRecipient
18
+ url: string
19
+ }): Promise<void> => {
20
+ await sendEmail({
21
+ to: params.to,
22
+ subject: 'Reset your password',
23
+ template: <ResetPasswordEmail name={params.to.name} url={params.url} />,
24
+ })
25
+ }
@@ -0,0 +1 @@
1
+ export { auth } from './config'
@@ -0,0 +1,9 @@
1
+ import { createServerFn } from '@tanstack/react-start'
2
+ import { getRequest } from '@tanstack/react-start/server'
3
+ import { auth } from '.'
4
+
5
+ /** Raw session lookup from the current request headers (server-side). */
6
+ export const getSession = async () => auth.api.getSession({ headers: getRequest().headers })
7
+
8
+ /** Server-function wrapper for route loaders / `beforeLoad`. */
9
+ export const getServerSession = createServerFn({ method: 'GET' }).handler(() => getSession())
@@ -0,0 +1,6 @@
1
+ import { drizzle } from 'drizzle-orm/node-postgres'
2
+
3
+ import { env } from '~/env'
4
+ import * as schema from '~/server/db/schemas'
5
+
6
+ export const db = drizzle(env.DATABASE_URL, { schema })
@@ -0,0 +1,63 @@
1
+ import { and, type Column, eq, gt, lt, or, type SQL } from 'drizzle-orm'
2
+
3
+ export type SortDirection = 'asc' | 'desc'
4
+
5
+ export const escapeLike = (value: string): string => value.replace(/[\\%_]/g, (char) => `\\${char}`)
6
+
7
+ export const encodeCursor = (value: string, id: string): string =>
8
+ Buffer.from(JSON.stringify([value, id])).toString('base64url')
9
+
10
+ export const decodeCursor = (cursor: string): { value: string; id: string } | null => {
11
+ try {
12
+ const [value, id] = JSON.parse(Buffer.from(cursor, 'base64url').toString())
13
+ if (typeof value !== 'string' || typeof id !== 'string') return null
14
+ return { value, id }
15
+ } catch {
16
+ return null
17
+ }
18
+ }
19
+
20
+ export const keysetCondition = ({
21
+ sortColumn,
22
+ idColumn,
23
+ direction,
24
+ cursor,
25
+ toComparable,
26
+ }: {
27
+ sortColumn: Column
28
+ idColumn: Column
29
+ direction: SortDirection
30
+ cursor: string
31
+ toComparable: (value: string) => unknown
32
+ }): SQL | undefined => {
33
+ const decoded = decodeCursor(cursor)
34
+ if (!decoded) return undefined
35
+
36
+ const comparable = toComparable(decoded.value)
37
+ const compare = direction === 'asc' ? gt : lt
38
+
39
+ return or(
40
+ compare(sortColumn, comparable),
41
+ and(eq(sortColumn, comparable), compare(idColumn, decoded.id)),
42
+ )
43
+ }
44
+
45
+ export const takePage = <T>({
46
+ rows,
47
+ limit,
48
+ getSortValue,
49
+ getId,
50
+ }: {
51
+ rows: T[]
52
+ limit: number
53
+ getSortValue: (row: T) => string
54
+ getId: (row: T) => string
55
+ }): { items: T[]; nextCursor: string | null } => {
56
+ if (rows.length <= limit) {
57
+ return { items: rows, nextCursor: null }
58
+ }
59
+
60
+ const items = rows.slice(0, limit)
61
+ const last = items[items.length - 1]
62
+ return { items, nextCursor: encodeCursor(getSortValue(last), getId(last)) }
63
+ }
@@ -0,0 +1,71 @@
1
+ import { relations } from 'drizzle-orm'
2
+ import { boolean, pgTable, text, timestamp } 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')
9
+ .$defaultFn(() => false)
10
+ .notNull(),
11
+ image: text('image'),
12
+ createdAt: timestamp('created_at')
13
+ .$defaultFn(() => new Date())
14
+ .notNull(),
15
+ updatedAt: timestamp('updated_at')
16
+ .$defaultFn(() => new Date())
17
+ .notNull(),
18
+ })
19
+
20
+ export const session = pgTable('session', {
21
+ id: text('id').primaryKey(),
22
+ expiresAt: timestamp('expires_at').notNull(),
23
+ token: text('token').notNull().unique(),
24
+ createdAt: timestamp('created_at').notNull(),
25
+ updatedAt: timestamp('updated_at').notNull(),
26
+ ipAddress: text('ip_address'),
27
+ userAgent: text('user_agent'),
28
+ userId: text('user_id')
29
+ .notNull()
30
+ .references(() => user.id, { onDelete: 'cascade' }),
31
+ })
32
+
33
+ export const account = pgTable('account', {
34
+ id: text('id').primaryKey(),
35
+ accountId: text('account_id').notNull(),
36
+ providerId: text('provider_id').notNull(),
37
+ userId: text('user_id')
38
+ .notNull()
39
+ .references(() => user.id, { onDelete: 'cascade' }),
40
+ accessToken: text('access_token'),
41
+ refreshToken: text('refresh_token'),
42
+ idToken: text('id_token'),
43
+ accessTokenExpiresAt: timestamp('access_token_expires_at'),
44
+ refreshTokenExpiresAt: timestamp('refresh_token_expires_at'),
45
+ scope: text('scope'),
46
+ password: text('password'),
47
+ createdAt: timestamp('created_at').notNull(),
48
+ updatedAt: timestamp('updated_at').notNull(),
49
+ })
50
+
51
+ export const verification = pgTable('verification', {
52
+ id: text('id').primaryKey(),
53
+ identifier: text('identifier').notNull(),
54
+ value: text('value').notNull(),
55
+ expiresAt: timestamp('expires_at').notNull(),
56
+ createdAt: timestamp('created_at').$defaultFn(() => new Date()),
57
+ updatedAt: timestamp('updated_at').$defaultFn(() => new Date()),
58
+ })
59
+
60
+ export const userRelations = relations(user, ({ many }) => ({
61
+ account: many(account),
62
+ session: many(session),
63
+ }))
64
+
65
+ export const accountRelations = relations(account, ({ one }) => ({
66
+ user: one(user, { fields: [account.userId], references: [user.id] }),
67
+ }))
68
+
69
+ export const sessionRelations = relations(session, ({ one }) => ({
70
+ user: one(user, { fields: [session.userId], references: [user.id] }),
71
+ }))
@@ -0,0 +1,2 @@
1
+ // Barrel of every Drizzle schema. Add one `export *` line per `*.schema.ts`.
2
+ export * from './auth.schema'
@@ -0,0 +1,27 @@
1
+ import { faker } from '@faker-js/faker'
2
+ import { config } from 'dotenv'
3
+
4
+ config({ path: ['.env.local', '.env'] })
5
+
6
+ // Deterministic data across runs.
7
+ faker.seed(42)
8
+
9
+ async function main() {
10
+ // Import the db client and seed your tables here (idempotent), using faker:
11
+ // const { db } = await import('~/server/db')
12
+ // await db.insert(user).values({
13
+ // id: faker.string.uuid(),
14
+ // name: faker.person.fullName(),
15
+ // email: faker.internet.email().toLowerCase(),
16
+ // }).onConflictDoNothing()
17
+
18
+ // biome-ignore lint/suspicious/noConsole: seed reporting
19
+ console.log('✓ Seed terminé')
20
+ }
21
+
22
+ main()
23
+ .then(() => process.exit(0))
24
+ .catch((error) => {
25
+ console.error(error)
26
+ process.exit(1)
27
+ })
@@ -0,0 +1,7 @@
1
+ import * as v from 'valibot'
2
+
3
+ export const ResendConfigSchema = v.object({
4
+ apiKey: v.pipe(v.string(), v.minLength(1, 'RESEND_API_KEY is required')),
5
+ })
6
+
7
+ export type ResendConfig = v.InferOutput<typeof ResendConfigSchema>
@@ -0,0 +1,75 @@
1
+ import { Resend } from 'resend'
2
+ import * as v from 'valibot'
3
+ import { formatAddress } from '../../core/address'
4
+ import { type MailerAdapter, MailerError, type RenderedMessage } from '../../core/port'
5
+ import { ResendConfigSchema } from './config'
6
+
7
+ /** Minimal structural view of the Resend client we depend on (eases testing). */
8
+ export interface ResendClient {
9
+ emails: {
10
+ send(payload: ResendSendPayload): Promise<{
11
+ data: { id: string } | null
12
+ error: { message: string; name?: string } | null
13
+ }>
14
+ }
15
+ }
16
+
17
+ interface ResendSendPayload {
18
+ from: string
19
+ to: string[]
20
+ subject: string
21
+ html: string
22
+ text: string
23
+ replyTo?: string
24
+ cc?: string[]
25
+ bcc?: string[]
26
+ headers?: Record<string, string>
27
+ tags?: { name: string; value: string }[]
28
+ attachments?: { filename: string; content: Buffer | string; contentType?: string }[]
29
+ }
30
+
31
+ export interface ResendAdapterOptions {
32
+ apiKey: string
33
+ /** Inject a custom/mock client. Defaults to a real `Resend` instance. */
34
+ client?: ResendClient
35
+ }
36
+
37
+ function toAttachmentContent(content: Uint8Array | string): Buffer | string {
38
+ return typeof content === 'string' ? content : Buffer.from(content)
39
+ }
40
+
41
+ export function resendAdapter(options: ResendAdapterOptions): MailerAdapter {
42
+ // Validate config early so a missing key fails at construction, not at send().
43
+ const config = v.parse(ResendConfigSchema, { apiKey: options.apiKey })
44
+ const client: ResendClient =
45
+ options.client ?? (new Resend(config.apiKey) as unknown as ResendClient)
46
+
47
+ return {
48
+ name: 'resend',
49
+ async send(message: RenderedMessage) {
50
+ const { data, error } = await client.emails.send({
51
+ from: formatAddress(message.from),
52
+ to: message.to.map(formatAddress),
53
+ subject: message.subject,
54
+ html: message.html,
55
+ text: message.text,
56
+ replyTo: message.replyTo ? formatAddress(message.replyTo) : undefined,
57
+ cc: message.cc?.map(formatAddress),
58
+ bcc: message.bcc?.map(formatAddress),
59
+ headers: message.headers,
60
+ tags: message.tags
61
+ ? Object.entries(message.tags).map(([name, value]) => ({ name, value }))
62
+ : undefined,
63
+ attachments: message.attachments?.map((a) => ({
64
+ filename: a.filename,
65
+ content: toAttachmentContent(a.content),
66
+ contentType: a.contentType,
67
+ })),
68
+ })
69
+
70
+ if (error) throw new MailerError(error.message, { adapter: 'resend', cause: error })
71
+ if (!data) throw new MailerError('Resend returned no data', { adapter: 'resend' })
72
+ return { id: data.id }
73
+ },
74
+ }
75
+ }
@@ -0,0 +1,21 @@
1
+ import type { MailAddress, MailRecipient } from './port'
2
+
3
+ const ADDRESS_RE = /^\s*(.*?)\s*<([^>]+)>\s*$/
4
+
5
+ /** Coerce a recipient into a structured {@link MailAddress}. */
6
+ export function normalizeAddress(input: MailRecipient): MailAddress {
7
+ if (typeof input !== 'string') return input
8
+ const match = input.match(ADDRESS_RE)
9
+ if (match?.[2]) return { name: match[1] || undefined, email: match[2].trim() }
10
+ return { email: input.trim() }
11
+ }
12
+
13
+ /** Coerce a single recipient or list into a normalized array. */
14
+ export function normalizeRecipients(input: MailRecipient | MailRecipient[]): MailAddress[] {
15
+ return (Array.isArray(input) ? input : [input]).map(normalizeAddress)
16
+ }
17
+
18
+ /** Render an address back to an RFC-style string (`Name <email>`). */
19
+ export function formatAddress(address: MailAddress): string {
20
+ return address.name ? `${address.name} <${address.email}>` : address.email
21
+ }