@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,66 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { Button } from '~/components/ui/button'
5
+ import { Spinner } from '~/components/ui/spinner'
6
+ import { authClient } from '~/server/better-auth/client'
7
+
8
+ function GoogleIcon() {
9
+ return (
10
+ <svg aria-hidden="true" className="size-4" viewBox="0 0 24 24">
11
+ <path
12
+ d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
13
+ fill="#4285F4"
14
+ />
15
+ <path
16
+ d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
17
+ fill="#34A853"
18
+ />
19
+ <path
20
+ d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
21
+ fill="#FBBC05"
22
+ />
23
+ <path
24
+ d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
25
+ fill="#EA4335"
26
+ />
27
+ </svg>
28
+ )
29
+ }
30
+
31
+ export function GoogleButton({
32
+ label,
33
+ callbackURL = '/',
34
+ }: {
35
+ label: string
36
+ callbackURL?: string
37
+ }) {
38
+ const [loading, setLoading] = useState(false)
39
+
40
+ return (
41
+ <Button
42
+ className="w-full cursor-pointer"
43
+ disabled={loading}
44
+ onClick={async () => {
45
+ setLoading(true)
46
+ await authClient.signIn.social({ provider: 'google', callbackURL })
47
+ setLoading(false)
48
+ }}
49
+ type="button"
50
+ variant="outline"
51
+ >
52
+ {loading ? <Spinner /> : <GoogleIcon />}
53
+ {label}
54
+ </Button>
55
+ )
56
+ }
57
+
58
+ export function AuthDivider() {
59
+ return (
60
+ <div className="flex items-center gap-3 text-muted-foreground text-xs">
61
+ <span className="h-px flex-1 bg-border" />
62
+ or
63
+ <span className="h-px flex-1 bg-border" />
64
+ </div>
65
+ )
66
+ }
@@ -0,0 +1,35 @@
1
+ import * as v from 'valibot'
2
+
3
+ export const SignInSchema = v.object({
4
+ email: v.pipe(v.string(), v.nonEmpty('Email is required'), v.email('Invalid email address')),
5
+ password: v.pipe(v.string(), v.nonEmpty('Password is required')),
6
+ })
7
+
8
+ export const SignUpSchema = v.object({
9
+ name: v.pipe(v.string(), v.nonEmpty('Name is required')),
10
+ email: v.pipe(v.string(), v.nonEmpty('Email is required'), v.email('Invalid email address')),
11
+ password: v.pipe(v.string(), v.minLength(8, 'At least 8 characters')),
12
+ })
13
+
14
+ export const ForgotPasswordSchema = v.object({
15
+ email: v.pipe(v.string(), v.nonEmpty('Email is required'), v.email('Invalid email address')),
16
+ })
17
+
18
+ export const ResetPasswordSchema = v.pipe(
19
+ v.object({
20
+ password: v.pipe(v.string(), v.minLength(8, 'At least 8 characters')),
21
+ confirmPassword: v.pipe(v.string(), v.nonEmpty('Confirmation is required')),
22
+ }),
23
+ v.forward(
24
+ v.check(
25
+ ({ password, confirmPassword }) => password === confirmPassword,
26
+ 'Passwords do not match',
27
+ ),
28
+ ['confirmPassword'],
29
+ ),
30
+ )
31
+
32
+ export type SignInInput = v.InferInput<typeof SignInSchema>
33
+ export type SignUpInput = v.InferInput<typeof SignUpSchema>
34
+ export type ForgotPasswordInput = v.InferInput<typeof ForgotPasswordSchema>
35
+ export type ResetPasswordInput = v.InferInput<typeof ResetPasswordSchema>
@@ -0,0 +1,4 @@
1
+ import { format } from 'date-fns'
2
+
3
+ /** Format a Date as an ISO date string (yyyy-MM-dd). */
4
+ export const toISODate = (date: Date): string => format(date, 'yyyy-MM-dd')
@@ -0,0 +1,6 @@
1
+ import { type ClassValue, clsx } from 'clsx'
2
+ import { twMerge } from 'tailwind-merge'
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }
@@ -0,0 +1,10 @@
1
+ import { createCallerFactory, createTRPCRouter } from '~/server/api/trpc'
2
+ import { healthRouter } from './routers/health.router'
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 '~/server/api/trpc'
2
+
3
+ export const healthRouter = createTRPCRouter({
4
+ ping: publicProcedure.query(() => ({
5
+ ok: true,
6
+ time: new Date().toISOString(),
7
+ })),
8
+ })
@@ -0,0 +1,56 @@
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 timingMiddleware = t.middleware(async ({ next, path }) => {
30
+ const start = Date.now()
31
+
32
+ if (t._config.isDev) {
33
+ const waitMs = Math.floor(Math.random() * 400) + 100
34
+ await new Promise((resolve) => setTimeout(resolve, waitMs))
35
+ }
36
+
37
+ const result = await next()
38
+
39
+ // biome-ignore lint/suspicious/noConsole: dev timing instrumentation
40
+ console.log(`[TRPC] ${path} took ${Date.now() - start}ms to execute`)
41
+
42
+ return result
43
+ })
44
+
45
+ export const publicProcedure = t.procedure.use(timingMiddleware)
46
+
47
+ export const protectedProcedure = t.procedure.use(timingMiddleware).use(({ ctx, next }) => {
48
+ if (!ctx.session?.user) {
49
+ throw new TRPCError({ code: 'UNAUTHORIZED' })
50
+ }
51
+ return next({
52
+ ctx: {
53
+ session: { ...ctx.session, user: ctx.session.user },
54
+ },
55
+ })
56
+ })
@@ -0,0 +1,10 @@
1
+ import 'server-only'
2
+ import { redirect } from 'next/navigation'
3
+ import { getSession } from '~/server/better-auth/server'
4
+
5
+ /** Require a signed-in user in a Server Component / page; redirect otherwise. */
6
+ export async function requireAuth() {
7
+ const session = await getSession()
8
+ if (!session) redirect('/auth/sign-in')
9
+ return session
10
+ }
@@ -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,60 @@
1
+ import { betterAuth } from 'better-auth'
2
+ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
3
+ import { env } from '~/env'
4
+ import { db } from '~/server/db'
5
+ import { sendPasswordResetEmail, sendVerificationEmail } from './emails'
6
+
7
+ const socialProviders =
8
+ env.BETTER_AUTH_GOOGLE_CLIENT_ID && env.BETTER_AUTH_GOOGLE_CLIENT_SECRET
9
+ ? {
10
+ google: {
11
+ clientId: env.BETTER_AUTH_GOOGLE_CLIENT_ID,
12
+ clientSecret: env.BETTER_AUTH_GOOGLE_CLIENT_SECRET,
13
+ },
14
+ }
15
+ : undefined
16
+
17
+ export const auth = betterAuth({
18
+ database: drizzleAdapter(db, {
19
+ provider: 'pg',
20
+ }),
21
+ emailAndPassword: {
22
+ enabled: true,
23
+ minPasswordLength: 8,
24
+ requireEmailVerification: true,
25
+ sendResetPassword: async ({ user, url }) => {
26
+ await sendPasswordResetEmail({ to: { email: user.email, name: user.name }, url })
27
+ },
28
+ },
29
+ emailVerification: {
30
+ sendOnSignUp: true,
31
+ autoSignInAfterVerification: true,
32
+ expiresIn: 60 * 60,
33
+ sendVerificationEmail: async ({ user, url }) => {
34
+ await sendVerificationEmail({ to: { email: user.email, name: user.name }, url })
35
+ },
36
+ },
37
+ socialProviders,
38
+ user: {
39
+ // Extend the user with extra columns here (mirror them in auth.schema.ts):
40
+ // additionalFields: {
41
+ // role: { type: 'string', defaultValue: 'user', input: false },
42
+ // },
43
+ additionalFields: {},
44
+ },
45
+ trustedOrigins: [env.BETTER_AUTH_URL],
46
+ rateLimit: {
47
+ enabled: env.NODE_ENV === 'production',
48
+ window: 60,
49
+ max: 100,
50
+ customRules: {
51
+ '/sign-up/email': { window: 60, max: 5 },
52
+ '/sign-in/email': { window: 60, max: 10 },
53
+ '/forget-password': { window: 60, max: 3 },
54
+ '/request-password-reset': { window: 60, max: 3 },
55
+ '/send-verification-email': { window: 60, max: 3 },
56
+ },
57
+ },
58
+ })
59
+
60
+ 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,14 @@
1
+ import { headers } from 'next/headers'
2
+ import { cache } from 'react'
3
+ import { auth } from '.'
4
+
5
+ /** Per-request cached session lookup (uses the cookie cache). */
6
+ export const getSession = cache(async () => auth.api.getSession({ headers: await headers() }))
7
+
8
+ /** Bypasses the cookie cache — use for sensitive checks. */
9
+ export const getFreshSession = cache(async () =>
10
+ auth.api.getSession({
11
+ headers: await headers(),
12
+ query: { disableCookieCache: true },
13
+ }),
14
+ )
@@ -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
+ }