@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,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,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,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,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
|
+
}
|