@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.
- package/README.md +56 -0
- package/_stack/apps/next-base/.dockerignore +10 -0
- package/_stack/apps/next-base/Dockerfile +34 -0
- package/_stack/apps/next-base/README.md +32 -0
- package/_stack/apps/next-base/components.json +25 -0
- package/_stack/apps/next-base/drizzle.config.ts +16 -0
- package/_stack/apps/next-base/next.config.ts +8 -0
- package/_stack/apps/next-base/package.json +70 -0
- package/_stack/apps/next-base/postcss.config.mjs +7 -0
- package/_stack/apps/next-base/src/app/api/auth/[...all]/route.ts +4 -0
- package/_stack/apps/next-base/src/app/api/trpc/[trpc]/route.ts +23 -0
- package/_stack/apps/next-base/src/app/auth/_components/forgot-password-form.tsx +92 -0
- package/_stack/apps/next-base/src/app/auth/_components/reset-password-form.tsx +105 -0
- package/_stack/apps/next-base/src/app/auth/_components/sign-in-form.tsx +126 -0
- package/_stack/apps/next-base/src/app/auth/_components/sign-up-form.tsx +139 -0
- package/_stack/apps/next-base/src/app/auth/_components/verify-email-actions.tsx +45 -0
- package/_stack/apps/next-base/src/app/auth/forgot-password/page.tsx +19 -0
- package/_stack/apps/next-base/src/app/auth/layout.tsx +9 -0
- package/_stack/apps/next-base/src/app/auth/reset-password/page.tsx +26 -0
- package/_stack/apps/next-base/src/app/auth/sign-in/page.tsx +27 -0
- package/_stack/apps/next-base/src/app/auth/sign-up/page.tsx +27 -0
- package/_stack/apps/next-base/src/app/auth/verify-email/page.tsx +30 -0
- package/_stack/apps/next-base/src/app/dashboard/page.tsx +12 -0
- package/_stack/apps/next-base/src/app/globals.css +171 -0
- package/_stack/apps/next-base/src/app/layout.tsx +23 -0
- package/_stack/apps/next-base/src/app/page.tsx +15 -0
- package/_stack/apps/next-base/src/components/data-table.tsx +77 -0
- package/_stack/apps/next-base/src/components/infinite-data-table.tsx +102 -0
- package/_stack/apps/next-base/src/components/sortable-header.tsx +37 -0
- package/_stack/apps/next-base/src/components/theme-provider.tsx +8 -0
- package/_stack/apps/next-base/src/components/theme-toggle.tsx +37 -0
- package/_stack/apps/next-base/src/components/ui/button.tsx +64 -0
- package/_stack/apps/next-base/src/components/ui/calendar.tsx +185 -0
- package/_stack/apps/next-base/src/components/ui/card.tsx +84 -0
- package/_stack/apps/next-base/src/components/ui/date-picker.tsx +85 -0
- package/_stack/apps/next-base/src/components/ui/date-range-picker.tsx +138 -0
- package/_stack/apps/next-base/src/components/ui/dropdown-menu.tsx +246 -0
- package/_stack/apps/next-base/src/components/ui/form.tsx +149 -0
- package/_stack/apps/next-base/src/components/ui/input-group.tsx +97 -0
- package/_stack/apps/next-base/src/components/ui/input.tsx +18 -0
- package/_stack/apps/next-base/src/components/ui/label.tsx +18 -0
- package/_stack/apps/next-base/src/components/ui/popover.tsx +76 -0
- package/_stack/apps/next-base/src/components/ui/skeleton.tsx +13 -0
- package/_stack/apps/next-base/src/components/ui/spinner.tsx +8 -0
- package/_stack/apps/next-base/src/components/ui/table.tsx +87 -0
- package/_stack/apps/next-base/src/emails/components/components.tsx +199 -0
- package/_stack/apps/next-base/src/emails/components/context.tsx +18 -0
- package/_stack/apps/next-base/src/emails/components/index.ts +23 -0
- package/_stack/apps/next-base/src/emails/components/theme.ts +65 -0
- package/_stack/apps/next-base/src/emails/reset-password.tsx +16 -0
- package/_stack/apps/next-base/src/emails/verify-email.tsx +15 -0
- package/_stack/apps/next-base/src/env.ts +41 -0
- package/_stack/apps/next-base/src/features/auth/auth-card.tsx +30 -0
- package/_stack/apps/next-base/src/features/auth/form-alert.tsx +27 -0
- package/_stack/apps/next-base/src/features/auth/google-button.tsx +66 -0
- package/_stack/apps/next-base/src/features/auth/schemas.ts +35 -0
- package/_stack/apps/next-base/src/lib/date.ts +4 -0
- package/_stack/apps/next-base/src/lib/utils.ts +6 -0
- package/_stack/apps/next-base/src/server/api/root.ts +10 -0
- package/_stack/apps/next-base/src/server/api/routers/health.router.ts +8 -0
- package/_stack/apps/next-base/src/server/api/trpc.ts +56 -0
- package/_stack/apps/next-base/src/server/auth/guards.ts +10 -0
- package/_stack/apps/next-base/src/server/better-auth/client.ts +9 -0
- package/_stack/apps/next-base/src/server/better-auth/config.ts +60 -0
- package/_stack/apps/next-base/src/server/better-auth/emails.tsx +25 -0
- package/_stack/apps/next-base/src/server/better-auth/index.ts +1 -0
- package/_stack/apps/next-base/src/server/better-auth/server.ts +14 -0
- package/_stack/apps/next-base/src/server/db/index.ts +6 -0
- package/_stack/apps/next-base/src/server/db/keyset.ts +63 -0
- package/_stack/apps/next-base/src/server/db/schemas/auth.schema.ts +71 -0
- package/_stack/apps/next-base/src/server/db/schemas/index.ts +2 -0
- package/_stack/apps/next-base/src/server/db/seed.ts +27 -0
- package/_stack/apps/next-base/src/server/email/adapters/resend/config.ts +7 -0
- package/_stack/apps/next-base/src/server/email/adapters/resend/index.ts +75 -0
- package/_stack/apps/next-base/src/server/email/core/address.ts +21 -0
- package/_stack/apps/next-base/src/server/email/core/port.ts +89 -0
- package/_stack/apps/next-base/src/server/email/core/render.ts +16 -0
- package/_stack/apps/next-base/src/server/email/factory.ts +47 -0
- package/_stack/apps/next-base/src/server/email/index.ts +36 -0
- package/_stack/apps/next-base/src/trpc/query-client.ts +19 -0
- package/_stack/apps/next-base/src/trpc/react.tsx +62 -0
- package/_stack/apps/next-base/src/trpc/server.ts +23 -0
- package/_stack/apps/next-base/tsconfig.json +37 -0
- package/_stack/apps/tanstack-base/.dockerignore +13 -0
- package/_stack/apps/tanstack-base/Dockerfile +28 -0
- package/_stack/apps/tanstack-base/README.md +31 -0
- package/_stack/apps/tanstack-base/components.json +25 -0
- package/_stack/apps/tanstack-base/drizzle.config.ts +16 -0
- package/_stack/apps/tanstack-base/package.json +85 -0
- package/_stack/apps/tanstack-base/public/favicon.ico +0 -0
- package/_stack/apps/tanstack-base/public/logo192.png +0 -0
- package/_stack/apps/tanstack-base/public/logo512.png +0 -0
- package/_stack/apps/tanstack-base/public/manifest.json +25 -0
- package/_stack/apps/tanstack-base/public/robots.txt +3 -0
- package/_stack/apps/tanstack-base/src/components/data-table.tsx +77 -0
- package/_stack/apps/tanstack-base/src/components/form/field-error.tsx +18 -0
- package/_stack/apps/tanstack-base/src/components/form/text-field.tsx +47 -0
- package/_stack/apps/tanstack-base/src/components/infinite-data-table.tsx +102 -0
- package/_stack/apps/tanstack-base/src/components/sortable-header.tsx +37 -0
- package/_stack/apps/tanstack-base/src/components/theme-provider.tsx +69 -0
- package/_stack/apps/tanstack-base/src/components/theme-toggle.tsx +35 -0
- package/_stack/apps/tanstack-base/src/components/ui/button.tsx +64 -0
- package/_stack/apps/tanstack-base/src/components/ui/calendar.tsx +185 -0
- package/_stack/apps/tanstack-base/src/components/ui/card.tsx +84 -0
- package/_stack/apps/tanstack-base/src/components/ui/date-picker.tsx +83 -0
- package/_stack/apps/tanstack-base/src/components/ui/date-range-picker.tsx +136 -0
- package/_stack/apps/tanstack-base/src/components/ui/dropdown-menu.tsx +246 -0
- package/_stack/apps/tanstack-base/src/components/ui/input-group.tsx +97 -0
- package/_stack/apps/tanstack-base/src/components/ui/input.tsx +18 -0
- package/_stack/apps/tanstack-base/src/components/ui/label.tsx +18 -0
- package/_stack/apps/tanstack-base/src/components/ui/popover.tsx +74 -0
- package/_stack/apps/tanstack-base/src/components/ui/skeleton.tsx +13 -0
- package/_stack/apps/tanstack-base/src/components/ui/spinner.tsx +8 -0
- package/_stack/apps/tanstack-base/src/components/ui/table.tsx +87 -0
- package/_stack/apps/tanstack-base/src/emails/components/components.tsx +199 -0
- package/_stack/apps/tanstack-base/src/emails/components/context.tsx +18 -0
- package/_stack/apps/tanstack-base/src/emails/components/index.ts +23 -0
- package/_stack/apps/tanstack-base/src/emails/components/theme.ts +65 -0
- package/_stack/apps/tanstack-base/src/emails/reset-password.tsx +16 -0
- package/_stack/apps/tanstack-base/src/emails/verify-email.tsx +15 -0
- package/_stack/apps/tanstack-base/src/env.ts +41 -0
- package/_stack/apps/tanstack-base/src/features/auth/auth-card.tsx +30 -0
- package/_stack/apps/tanstack-base/src/features/auth/form-alert.tsx +27 -0
- package/_stack/apps/tanstack-base/src/features/auth/google-button.tsx +64 -0
- package/_stack/apps/tanstack-base/src/features/auth/schemas.ts +35 -0
- package/_stack/apps/tanstack-base/src/lib/date.ts +4 -0
- package/_stack/apps/tanstack-base/src/lib/utils.ts +6 -0
- package/_stack/apps/tanstack-base/src/router.tsx +40 -0
- package/_stack/apps/tanstack-base/src/routes/__root.tsx +73 -0
- package/_stack/apps/tanstack-base/src/routes/_authed/dashboard.tsx +12 -0
- package/_stack/apps/tanstack-base/src/routes/_authed.tsx +21 -0
- package/_stack/apps/tanstack-base/src/routes/api/auth/$.ts +14 -0
- package/_stack/apps/tanstack-base/src/routes/api.trpc.$.tsx +31 -0
- package/_stack/apps/tanstack-base/src/routes/auth/forgot-password.tsx +89 -0
- package/_stack/apps/tanstack-base/src/routes/auth/reset-password.tsx +111 -0
- package/_stack/apps/tanstack-base/src/routes/auth/sign-in.tsx +117 -0
- package/_stack/apps/tanstack-base/src/routes/auth/sign-up.tsx +119 -0
- package/_stack/apps/tanstack-base/src/routes/auth/verify-email.tsx +72 -0
- package/_stack/apps/tanstack-base/src/routes/auth.tsx +22 -0
- package/_stack/apps/tanstack-base/src/routes/index.tsx +18 -0
- package/_stack/apps/tanstack-base/src/server/api/root.ts +10 -0
- package/_stack/apps/tanstack-base/src/server/api/routers/health.router.ts +8 -0
- package/_stack/apps/tanstack-base/src/server/api/trpc.ts +61 -0
- package/_stack/apps/tanstack-base/src/server/better-auth/client.ts +9 -0
- package/_stack/apps/tanstack-base/src/server/better-auth/config.ts +68 -0
- package/_stack/apps/tanstack-base/src/server/better-auth/emails.tsx +25 -0
- package/_stack/apps/tanstack-base/src/server/better-auth/index.ts +1 -0
- package/_stack/apps/tanstack-base/src/server/better-auth/session.ts +9 -0
- package/_stack/apps/tanstack-base/src/server/db/index.ts +6 -0
- package/_stack/apps/tanstack-base/src/server/db/keyset.ts +63 -0
- package/_stack/apps/tanstack-base/src/server/db/schemas/auth.schema.ts +71 -0
- package/_stack/apps/tanstack-base/src/server/db/schemas/index.ts +2 -0
- package/_stack/apps/tanstack-base/src/server/db/seed.ts +27 -0
- package/_stack/apps/tanstack-base/src/server/email/adapters/resend/config.ts +7 -0
- package/_stack/apps/tanstack-base/src/server/email/adapters/resend/index.ts +75 -0
- package/_stack/apps/tanstack-base/src/server/email/core/address.ts +21 -0
- package/_stack/apps/tanstack-base/src/server/email/core/port.ts +89 -0
- package/_stack/apps/tanstack-base/src/server/email/core/render.ts +16 -0
- package/_stack/apps/tanstack-base/src/server/email/factory.ts +47 -0
- package/_stack/apps/tanstack-base/src/server/email/index.ts +36 -0
- package/_stack/apps/tanstack-base/src/styles.css +171 -0
- package/_stack/apps/tanstack-base/src/trpc/devtools.tsx +6 -0
- package/_stack/apps/tanstack-base/src/trpc/query-client.ts +19 -0
- package/_stack/apps/tanstack-base/src/trpc/react.tsx +49 -0
- package/_stack/apps/tanstack-base/src/trpc/server.ts +11 -0
- package/_stack/apps/tanstack-base/tsconfig.json +27 -0
- package/_stack/apps/tanstack-base/tsr.config.json +3 -0
- package/_stack/apps/tanstack-base/vite.config.ts +15 -0
- package/_stack/packages/analytics/capability.json +26 -0
- package/_stack/packages/cache/capability.json +21 -0
- package/_stack/packages/error-tracking/capability.json +21 -0
- package/_stack/packages/jobs/capability.json +26 -0
- package/_stack/packages/logger/capability.json +21 -0
- package/_stack/packages/mailer/capability.json +28 -0
- package/_stack/packages/mailer/package.json +37 -0
- package/_stack/packages/mailer/src/adapters/brevo/config.ts +7 -0
- package/_stack/packages/mailer/src/adapters/brevo/index.ts +90 -0
- package/_stack/packages/mailer/src/adapters/resend/config.ts +7 -0
- package/_stack/packages/mailer/src/adapters/resend/index.ts +75 -0
- package/_stack/packages/mailer/src/adapters/ses/config.ts +13 -0
- package/_stack/packages/mailer/src/adapters/ses/index.ts +103 -0
- package/_stack/packages/storage/capability.json +32 -0
- package/_stack/patterns/README.md +58 -0
- package/_stack/patterns/_baseline/README-author.md +10 -0
- package/_stack/patterns/_baseline/biome.jsonc +119 -0
- package/_stack/patterns/_baseline/env.ts +31 -0
- package/_stack/patterns/_baseline/tsconfig.json +27 -0
- package/_stack/patterns/better-auth/pattern.json +73 -0
- package/_stack/patterns/better-auth-next/pattern.json +76 -0
- package/_stack/patterns/data-table/pattern.json +43 -0
- package/_stack/patterns/drizzle/pattern.json +61 -0
- package/_stack/patterns/trpc/pattern.json +61 -0
- package/_stack/patterns/trpc-next/pattern.json +64 -0
- package/index.mjs +216 -0
- package/lib/build.mjs +64 -0
- package/lib/env.mjs +56 -0
- package/lib/identity.mjs +33 -0
- package/lib/mailer.mjs +95 -0
- package/lib/manifests.mjs +61 -0
- package/lib/scaffold.mjs +49 -0
- package/lib/strip.mjs +132 -0
- package/lib/util.mjs +82 -0
- package/package.json +51 -0
- package/templates/next/layout.no-trpc.tsx +22 -0
- package/templates/tanstack/__root.no-trpc.tsx +63 -0
- 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,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,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,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,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
|
+
}
|