create-ej-app 25.0.0 → 26.0.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 (160) hide show
  1. package/cli.js +4 -0
  2. package/cli.ts +5 -0
  3. package/package.json +2 -2
  4. package/template/api/package.json +2 -2
  5. package/template/nextjs/gitignore +1 -0
  6. package/template/nextjs/package.json +18 -18
  7. package/template/saas-starter/.dockerignore +7 -0
  8. package/template/saas-starter/.env.example +25 -0
  9. package/template/saas-starter/.vscode/extensions.json +11 -0
  10. package/template/saas-starter/.vscode/settings.json +3 -0
  11. package/template/saas-starter/Dockerfile +15 -0
  12. package/template/saas-starter/README.md +3 -0
  13. package/template/saas-starter/components.json +25 -0
  14. package/template/saas-starter/drizzle.config.ts +10 -0
  15. package/template/saas-starter/eslint.config.mjs +23 -0
  16. package/template/saas-starter/gitignore +9 -0
  17. package/template/saas-starter/next-env.d.ts +6 -0
  18. package/template/saas-starter/next.config.ts +14 -0
  19. package/template/saas-starter/package.json +92 -0
  20. package/template/saas-starter/postcss.config.mjs +7 -0
  21. package/template/saas-starter/prettier.config.mjs +11 -0
  22. package/template/saas-starter/public/placeholder.svg +1 -0
  23. package/template/saas-starter/src/actions/uploads.ts +28 -0
  24. package/template/saas-starter/src/actions/uploads.validation.ts +14 -0
  25. package/template/saas-starter/src/app/(admin)/actions/users.ts +97 -0
  26. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +21 -0
  27. package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  28. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +40 -0
  29. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +57 -0
  30. package/template/saas-starter/src/app/(admin)/admin/page.tsx +3 -0
  31. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +183 -0
  32. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +129 -0
  33. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +415 -0
  34. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +15 -0
  35. package/template/saas-starter/src/app/(app)/actions/billing.ts +152 -0
  36. package/template/saas-starter/src/app/(app)/actions/members.ts +163 -0
  37. package/template/saas-starter/src/app/(app)/actions/organization.ts +92 -0
  38. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +20 -0
  39. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +34 -0
  40. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +100 -0
  41. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +364 -0
  42. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +155 -0
  43. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +232 -0
  44. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +116 -0
  45. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +169 -0
  46. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +571 -0
  47. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +40 -0
  48. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +69 -0
  49. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +29 -0
  50. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +17 -0
  51. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +27 -0
  52. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +41 -0
  53. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +140 -0
  54. package/template/saas-starter/src/app/(app)/app/layout.tsx +52 -0
  55. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +129 -0
  56. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +42 -0
  57. package/template/saas-starter/src/app/(app)/app/page.tsx +81 -0
  58. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +109 -0
  59. package/template/saas-starter/src/app/(auth)/login/page.tsx +87 -0
  60. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +75 -0
  61. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +88 -0
  62. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +167 -0
  63. package/template/saas-starter/src/app/(marketing)/layout.tsx +14 -0
  64. package/template/saas-starter/src/app/(marketing)/page.tsx +3 -0
  65. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +3 -0
  66. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +3 -0
  67. package/template/saas-starter/src/app/api/auth/[...all]/route.ts +4 -0
  68. package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
  69. package/template/saas-starter/src/app/favicon.ico +0 -0
  70. package/template/saas-starter/src/app/globals.css +240 -0
  71. package/template/saas-starter/src/app/layout.tsx +53 -0
  72. package/template/saas-starter/src/app/not-found.tsx +27 -0
  73. package/template/saas-starter/src/app/proxy.ts +28 -0
  74. package/template/saas-starter/src/app/robots.ts +32 -0
  75. package/template/saas-starter/src/app/sitemap.ts +20 -0
  76. package/template/saas-starter/src/components/dashboard-header.tsx +64 -0
  77. package/template/saas-starter/src/components/data-table/enum.ts +5 -0
  78. package/template/saas-starter/src/components/data-table/index.tsx +960 -0
  79. package/template/saas-starter/src/components/data-table/types.ts +75 -0
  80. package/template/saas-starter/src/components/error.tsx +26 -0
  81. package/template/saas-starter/src/components/file-upload.tsx +215 -0
  82. package/template/saas-starter/src/components/impersonation-banner.tsx +44 -0
  83. package/template/saas-starter/src/components/loader.tsx +25 -0
  84. package/template/saas-starter/src/components/navigation-search.tsx +140 -0
  85. package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
  86. package/template/saas-starter/src/components/page-heading.tsx +32 -0
  87. package/template/saas-starter/src/components/react-query-provider.tsx +12 -0
  88. package/template/saas-starter/src/components/screen-size.tsx +40 -0
  89. package/template/saas-starter/src/components/site-footer.tsx +23 -0
  90. package/template/saas-starter/src/components/theme-provider.tsx +67 -0
  91. package/template/saas-starter/src/components/theme-toggle.tsx +22 -0
  92. package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
  93. package/template/saas-starter/src/components/ui/badge.tsx +49 -0
  94. package/template/saas-starter/src/components/ui/button.tsx +67 -0
  95. package/template/saas-starter/src/components/ui/card.tsx +103 -0
  96. package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
  97. package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
  98. package/template/saas-starter/src/components/ui/command.tsx +192 -0
  99. package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
  100. package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
  101. package/template/saas-starter/src/components/ui/field.tsx +238 -0
  102. package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
  103. package/template/saas-starter/src/components/ui/input.tsx +19 -0
  104. package/template/saas-starter/src/components/ui/label.tsx +24 -0
  105. package/template/saas-starter/src/components/ui/popover.tsx +89 -0
  106. package/template/saas-starter/src/components/ui/select.tsx +195 -0
  107. package/template/saas-starter/src/components/ui/separator.tsx +28 -0
  108. package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
  109. package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
  110. package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
  111. package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
  112. package/template/saas-starter/src/components/ui/switch.tsx +33 -0
  113. package/template/saas-starter/src/components/ui/table.tsx +116 -0
  114. package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
  115. package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
  116. package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
  117. package/template/saas-starter/src/components/user-menu.tsx +90 -0
  118. package/template/saas-starter/src/db/common.ts +31 -0
  119. package/template/saas-starter/src/db/enums.ts +26 -0
  120. package/template/saas-starter/src/db/index.ts +51 -0
  121. package/template/saas-starter/src/db/relations.ts +75 -0
  122. package/template/saas-starter/src/db/schema.ts +191 -0
  123. package/template/saas-starter/src/emails/components/email-layout.tsx +63 -0
  124. package/template/saas-starter/src/emails/invitation.tsx +56 -0
  125. package/template/saas-starter/src/emails/magic-link.tsx +49 -0
  126. package/template/saas-starter/src/emails/test.tsx +32 -0
  127. package/template/saas-starter/src/emails/welcome.tsx +34 -0
  128. package/template/saas-starter/src/env.ts +66 -0
  129. package/template/saas-starter/src/hooks/use-mobile.ts +19 -0
  130. package/template/saas-starter/src/instrumentation.ts +26 -0
  131. package/template/saas-starter/src/lib/actions.ts +21 -0
  132. package/template/saas-starter/src/lib/admin-navigation.ts +31 -0
  133. package/template/saas-starter/src/lib/app-navigation.ts +59 -0
  134. package/template/saas-starter/src/lib/auth-client.ts +29 -0
  135. package/template/saas-starter/src/lib/auth-permissions.ts +22 -0
  136. package/template/saas-starter/src/lib/auth.ts +211 -0
  137. package/template/saas-starter/src/lib/billing.ts +117 -0
  138. package/template/saas-starter/src/lib/cn.ts +4 -0
  139. package/template/saas-starter/src/lib/constants.ts +22 -0
  140. package/template/saas-starter/src/lib/delay.ts +2 -0
  141. package/template/saas-starter/src/lib/email-service.ts +89 -0
  142. package/template/saas-starter/src/lib/format-currency.ts +27 -0
  143. package/template/saas-starter/src/lib/format-date.ts +12 -0
  144. package/template/saas-starter/src/lib/nodemailer.ts +43 -0
  145. package/template/saas-starter/src/lib/organization-access.ts +93 -0
  146. package/template/saas-starter/src/lib/organization-plan-limits.ts +38 -0
  147. package/template/saas-starter/src/lib/otel.ts +27 -0
  148. package/template/saas-starter/src/lib/plans.ts +63 -0
  149. package/template/saas-starter/src/lib/safe-action-client.ts +75 -0
  150. package/template/saas-starter/src/lib/safe-action.ts +67 -0
  151. package/template/saas-starter/src/lib/siteConfig.ts +4 -0
  152. package/template/saas-starter/src/lib/storage.ts +181 -0
  153. package/template/saas-starter/src/lib/stripe.ts +6 -0
  154. package/template/saas-starter/src/lib/toast-message.ts +15 -0
  155. package/template/saas-starter/src/lib/validations.ts +12 -0
  156. package/template/saas-starter/src/scripts/reset-db.ts +22 -0
  157. package/template/saas-starter/src/scripts/seed.ts +11 -0
  158. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +200 -0
  159. package/template/saas-starter/src/scripts/test-mail.ts +35 -0
  160. package/template/saas-starter/tsconfig.json +51 -0
@@ -0,0 +1,87 @@
1
+ import { Terminal } from 'lucide-react'
2
+ import Link from 'next/link'
3
+ import { redirect } from 'next/navigation'
4
+ import {
5
+ Card,
6
+ CardContent,
7
+ CardDescription,
8
+ CardFooter,
9
+ CardHeader,
10
+ CardTitle
11
+ } from '~/components/ui/card'
12
+ import { Separator } from '~/components/ui/separator'
13
+ import { getAuthSession } from '~/lib/auth'
14
+ import { EmailLoginForm } from './email-login-form'
15
+ import { SocialLoginButtons } from './social-login-buttons'
16
+
17
+ function sanitizeCallback(value: string | string[] | undefined) {
18
+ if (typeof value !== 'string') return null
19
+ if (!value.startsWith('/') || value.startsWith('//')) return null
20
+ return value
21
+ }
22
+
23
+ export default async function LoginPage({
24
+ searchParams
25
+ }: {
26
+ searchParams?: Promise<Record<string, string | string[] | undefined>>
27
+ }) {
28
+ const resolvedParams = (await searchParams) ?? {}
29
+ const callbackUrl = sanitizeCallback(resolvedParams.callbackUrl)
30
+
31
+ const authSession = await getAuthSession()
32
+
33
+ if (authSession) {
34
+ return redirect(callbackUrl ?? (authSession.isAdmin ? '/admin' : '/app'))
35
+ }
36
+
37
+ return (
38
+ <main className="flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
39
+ <Card className="w-full max-w-md">
40
+ <CardHeader className="space-y-1 text-center">
41
+ <div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
42
+ <Terminal className="text-primary size-6" />
43
+ </div>
44
+ <CardTitle className="text-2xl font-bold">Welcome back</CardTitle>
45
+ <CardDescription className="text-muted-foreground text-sm">
46
+ Sign in to your account to continue
47
+ </CardDescription>
48
+ </CardHeader>
49
+ <CardContent className="space-y-4">
50
+ <div className="border-muted-foreground/50 bg-muted/50 rounded-lg border border-dashed p-4">
51
+ <p className="text-muted-foreground text-sm">
52
+ <strong>New to our site?</strong> No need to create a separate
53
+ account. Simply use one of the options below to both sign up and
54
+ log in.
55
+ </p>
56
+ </div>
57
+
58
+ <EmailLoginForm callbackUrl={callbackUrl} />
59
+
60
+ <div className="relative">
61
+ <div className="absolute inset-0 flex items-center">
62
+ <span className="w-full border-t" />
63
+ </div>
64
+ <div className="relative flex justify-center text-xs uppercase">
65
+ <span className="bg-background text-muted-foreground px-2">
66
+ Or continue with
67
+ </span>
68
+ </div>
69
+ </div>
70
+
71
+ <SocialLoginButtons callbackUrl={callbackUrl} />
72
+ </CardContent>
73
+ <CardFooter className="flex flex-col space-y-4">
74
+ <Separator className="my-2" />
75
+ <div className="text-muted-foreground text-center text-sm">
76
+ <Link
77
+ href="/"
78
+ className="text-primary underline-offset-4 hover:underline"
79
+ >
80
+ Return to home page
81
+ </Link>
82
+ </div>
83
+ </CardFooter>
84
+ </Card>
85
+ </main>
86
+ )
87
+ }
@@ -0,0 +1,75 @@
1
+ 'use client'
2
+
3
+ import { Button } from '~/components/ui/button'
4
+ import { getLastUsedLoginMethod, signIn } from '~/lib/auth-client'
5
+
6
+ export const SocialLoginButtons = ({
7
+ callbackUrl
8
+ }: {
9
+ callbackUrl?: string | null
10
+ }) => {
11
+ const lastUsedLoginMethod = getLastUsedLoginMethod()
12
+
13
+ return (
14
+ <div className="flex flex-col space-y-2">
15
+ <Button
16
+ variant="outline"
17
+ onClick={async () => {
18
+ await signIn.social({
19
+ provider: 'google',
20
+ callbackURL: callbackUrl || '/login'
21
+ })
22
+ }}
23
+ >
24
+ <svg
25
+ viewBox="0 0 24 24"
26
+ width="16"
27
+ height="16"
28
+ xmlns="http://www.w3.org/2000/svg"
29
+ >
30
+ <g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
31
+ <path
32
+ fill="#4285F4"
33
+ d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"
34
+ />
35
+ <path
36
+ fill="#34A853"
37
+ d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"
38
+ />
39
+ <path
40
+ fill="#FBBC05"
41
+ d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"
42
+ />
43
+ <path
44
+ fill="#EA4335"
45
+ d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"
46
+ />
47
+ </g>
48
+ </svg>
49
+ Continue with Google
50
+ {lastUsedLoginMethod === 'google' ? ' (last used)' : ''}
51
+ </Button>
52
+
53
+ <Button
54
+ variant="outline"
55
+ onClick={async () => {
56
+ await signIn.social({
57
+ provider: 'github',
58
+ callbackURL: callbackUrl || '/login'
59
+ })
60
+ }}
61
+ >
62
+ <svg
63
+ viewBox="0 0 24 24"
64
+ width="16"
65
+ height="16"
66
+ xmlns="http://www.w3.org/2000/svg"
67
+ >
68
+ <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
69
+ </svg>
70
+ Continue with GitHub
71
+ {lastUsedLoginMethod === 'github' ? ' (last used)' : ''}
72
+ </Button>
73
+ </div>
74
+ )
75
+ }
@@ -0,0 +1,88 @@
1
+ 'use client'
2
+
3
+ import Link from 'next/link'
4
+ import { useRouter } from 'next/navigation'
5
+ import { useState } from 'react'
6
+ import { toast } from 'sonner'
7
+ import { Button } from '~/components/ui/button'
8
+ import { organization, signOut } from '~/lib/auth-client'
9
+
10
+ export function AcceptInvitationForm({
11
+ invitationId,
12
+ organizationName
13
+ }: {
14
+ invitationId: string
15
+ organizationName: string
16
+ }) {
17
+ const router = useRouter()
18
+ const [accepting, setAccepting] = useState(false)
19
+ const [declining, setDeclining] = useState(false)
20
+
21
+ async function handleAccept() {
22
+ setAccepting(true)
23
+ const { error } = await organization.acceptInvitation({ invitationId })
24
+ if (error) {
25
+ toast.error(error.message ?? 'Failed to accept invitation')
26
+ setAccepting(false)
27
+ return
28
+ }
29
+ toast.success(`You've joined ${organizationName}`)
30
+ router.push('/app')
31
+ }
32
+
33
+ async function handleDecline() {
34
+ setDeclining(true)
35
+ const { error } = await organization.rejectInvitation({ invitationId })
36
+ if (error) {
37
+ toast.error(error.message ?? 'Failed to decline invitation')
38
+ setDeclining(false)
39
+ return
40
+ }
41
+ toast('Invitation declined')
42
+ router.push('/')
43
+ }
44
+
45
+ const disabled = accepting || declining
46
+
47
+ return (
48
+ <div className="flex flex-col gap-3">
49
+ <Button onClick={handleAccept} disabled={disabled} className="w-full">
50
+ {accepting ? 'Accepting...' : 'Accept invitation'}
51
+ </Button>
52
+ <Button
53
+ onClick={handleDecline}
54
+ disabled={disabled}
55
+ variant="outline"
56
+ className="w-full"
57
+ >
58
+ {declining ? 'Declining...' : 'Decline'}
59
+ </Button>
60
+ </div>
61
+ )
62
+ }
63
+
64
+ export function WrongAccountActions({
65
+ invitationId
66
+ }: {
67
+ invitationId: string
68
+ }) {
69
+ const router = useRouter()
70
+ const [loading, setLoading] = useState(false)
71
+
72
+ async function handleSwitch() {
73
+ setLoading(true)
74
+ await signOut()
75
+ router.push(`/login?callbackUrl=/accept-invitation/${invitationId}`)
76
+ }
77
+
78
+ return (
79
+ <div className="flex justify-center gap-3">
80
+ <Button asChild variant="outline">
81
+ <Link href="/">Return home</Link>
82
+ </Button>
83
+ <Button onClick={handleSwitch} disabled={loading}>
84
+ {loading ? 'Signing out...' : 'Switch account'}
85
+ </Button>
86
+ </div>
87
+ )
88
+ }
@@ -0,0 +1,167 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import { Building2 } from 'lucide-react'
3
+ import Link from 'next/link'
4
+ import { Button } from '~/components/ui/button'
5
+ import {
6
+ Card,
7
+ CardContent,
8
+ CardDescription,
9
+ CardFooter,
10
+ CardHeader,
11
+ CardTitle
12
+ } from '~/components/ui/card'
13
+ import { db } from '~/db'
14
+ import { invitationsTable } from '~/db/schema'
15
+ import { getAuthSession } from '~/lib/auth'
16
+ import {
17
+ AcceptInvitationForm,
18
+ WrongAccountActions
19
+ } from './accept-invitation-form'
20
+
21
+ export default async function AcceptInvitationPage({
22
+ params
23
+ }: {
24
+ params: Promise<{ id: string }>
25
+ }) {
26
+ const { id } = await params
27
+
28
+ const invitation = await db.query.invitationsTable.findFirst({
29
+ where: eq(invitationsTable.id, id),
30
+ with: {
31
+ organization: {
32
+ columns: {
33
+ id: true,
34
+ name: true
35
+ }
36
+ },
37
+ user: {
38
+ columns: {
39
+ id: true,
40
+ name: true
41
+ }
42
+ }
43
+ },
44
+ columns: {
45
+ id: true,
46
+ status: true,
47
+ expiresAt: true,
48
+ email: true,
49
+ role: true
50
+ }
51
+ })
52
+
53
+ if (!invitation || invitation.status !== 'pending') {
54
+ return (
55
+ <main className="flex min-h-screen items-center justify-center px-4 py-12">
56
+ <Card className="w-full max-w-md text-center">
57
+ <CardHeader>
58
+ <CardTitle>Invitation not found</CardTitle>
59
+ <CardDescription>
60
+ This invitation link is invalid or has already been used.
61
+ </CardDescription>
62
+ </CardHeader>
63
+ <CardFooter className="justify-center">
64
+ <Button asChild variant="outline">
65
+ <Link href="/">Return home</Link>
66
+ </Button>
67
+ </CardFooter>
68
+ </Card>
69
+ </main>
70
+ )
71
+ }
72
+
73
+ if (invitation.expiresAt && invitation.expiresAt < new Date()) {
74
+ return (
75
+ <main className="flex min-h-screen items-center justify-center px-4 py-12">
76
+ <Card className="w-full max-w-md text-center">
77
+ <CardHeader>
78
+ <CardTitle>Invitation expired</CardTitle>
79
+ <CardDescription>
80
+ This invitation to <strong>{invitation.organization.name}</strong>{' '}
81
+ has expired. Ask your organization admin for a new one.
82
+ </CardDescription>
83
+ </CardHeader>
84
+ <CardFooter className="justify-center">
85
+ <Button asChild variant="outline">
86
+ <Link href="/">Return home</Link>
87
+ </Button>
88
+ </CardFooter>
89
+ </Card>
90
+ </main>
91
+ )
92
+ }
93
+
94
+ const authSession = await getAuthSession()
95
+
96
+ if (!authSession) {
97
+ return (
98
+ <main className="flex min-h-screen items-center justify-center px-4 py-12">
99
+ <Card className="w-full max-w-md">
100
+ <CardHeader className="text-center">
101
+ <div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
102
+ <Building2 className="text-primary size-6" />
103
+ </div>
104
+ <CardTitle>You&apos;ve been invited</CardTitle>
105
+ <CardDescription>
106
+ <strong>{invitation.user.name}</strong> invited you to join{' '}
107
+ <strong>{invitation.organization.name}</strong>. Sign in to
108
+ accept.
109
+ </CardDescription>
110
+ </CardHeader>
111
+ <CardContent>
112
+ <Button asChild className="w-full">
113
+ <Link href={`/login?callbackUrl=/accept-invitation/${id}`}>
114
+ Sign in to accept
115
+ </Link>
116
+ </Button>
117
+ </CardContent>
118
+ </Card>
119
+ </main>
120
+ )
121
+ }
122
+
123
+ if (authSession.user.email.toLowerCase() !== invitation.email.toLowerCase()) {
124
+ return (
125
+ <main className="flex min-h-screen items-center justify-center px-4 py-12">
126
+ <Card className="w-full max-w-md text-center">
127
+ <CardHeader>
128
+ <CardTitle>Wrong account</CardTitle>
129
+ <CardDescription>
130
+ This invitation was sent to <strong>{invitation.email}</strong>{' '}
131
+ but you&apos;re signed in as{' '}
132
+ <strong>{authSession.user.email}</strong>. Sign in with the
133
+ correct account to accept.
134
+ </CardDescription>
135
+ </CardHeader>
136
+ <CardFooter className="justify-center">
137
+ <WrongAccountActions invitationId={id} />
138
+ </CardFooter>
139
+ </Card>
140
+ </main>
141
+ )
142
+ }
143
+
144
+ return (
145
+ <main className="flex min-h-screen items-center justify-center px-4 py-12">
146
+ <Card className="w-full max-w-md">
147
+ <CardHeader className="text-center">
148
+ <div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
149
+ <Building2 className="text-primary size-6" />
150
+ </div>
151
+ <CardTitle>Join {invitation.organization.name}</CardTitle>
152
+ <CardDescription>
153
+ <strong>{invitation.user.name}</strong> has invited you to join{' '}
154
+ <strong>{invitation.organization.name}</strong>
155
+ {invitation.role ? ` as ${invitation.role}` : ''}.
156
+ </CardDescription>
157
+ </CardHeader>
158
+ <CardContent>
159
+ <AcceptInvitationForm
160
+ invitationId={id}
161
+ organizationName={invitation.organization.name}
162
+ />
163
+ </CardContent>
164
+ </Card>
165
+ </main>
166
+ )
167
+ }
@@ -0,0 +1,14 @@
1
+ import { SiteFooter } from '~/components/site-footer'
2
+
3
+ export default async function Layout({
4
+ children
5
+ }: Readonly<{
6
+ children: React.ReactNode
7
+ }>) {
8
+ return (
9
+ <div className="flex min-h-screen flex-col space-y-4">
10
+ <main className="flex-1">{children}</main>
11
+ <SiteFooter />
12
+ </div>
13
+ )
14
+ }
@@ -0,0 +1,3 @@
1
+ export default async function HomePage() {
2
+ return <div>Hello, World!</div>
3
+ }
@@ -0,0 +1,3 @@
1
+ export default async function PrivacyPage() {
2
+ return <div>Hello, World!</div>
3
+ }
@@ -0,0 +1,3 @@
1
+ export default async function TermsPage() {
2
+ return <div>Hello, World!</div>
3
+ }
@@ -0,0 +1,4 @@
1
+ import { toNextJsHandler } from 'better-auth/next-js'
2
+ import { auth } from '~/lib/auth'
3
+
4
+ export const { GET, POST } = toNextJsHandler(auth.handler)
@@ -0,0 +1,94 @@
1
+ import { eq } from 'drizzle-orm'
2
+ import { type NextRequest, NextResponse } from 'next/server'
3
+ import type Stripe from 'stripe'
4
+ import { db } from '~/db'
5
+ import { stripeWebhooksEventsTable } from '~/db/schema'
6
+ import { env } from '~/env'
7
+ import { syncSubscriptionFromStripe } from '~/lib/billing'
8
+ import { stripeClient } from '~/lib/stripe'
9
+
10
+ const RELEVANT_EVENTS: ReadonlySet<string> = new Set([
11
+ 'checkout.session.completed',
12
+ 'customer.subscription.created',
13
+ 'customer.subscription.updated',
14
+ 'customer.subscription.deleted',
15
+ 'customer.subscription.trial_will_end',
16
+ 'customer.subscription.paused',
17
+ 'customer.subscription.resumed',
18
+ 'invoice.paid',
19
+ 'invoice.payment_succeeded',
20
+ 'invoice.payment_failed'
21
+ ])
22
+
23
+ const extractCustomerId = (event: Stripe.Event): string | null => {
24
+ const object = event.data.object as {
25
+ customer?: string | { id: string } | null
26
+ }
27
+ const customer = object.customer
28
+ if (!customer) return null
29
+ return typeof customer === 'string' ? customer : customer.id
30
+ }
31
+
32
+ export async function POST(req: NextRequest) {
33
+ const body = await req.text()
34
+ const signature = req.headers.get('stripe-signature')
35
+
36
+ if (!signature) {
37
+ return new NextResponse('Missing stripe-signature header', { status: 400 })
38
+ }
39
+
40
+ let event: Stripe.Event
41
+ try {
42
+ event = stripeClient.webhooks.constructEvent(
43
+ body,
44
+ signature,
45
+ env.STRIPE_WEBHOOK_SECRET
46
+ )
47
+ } catch (error) {
48
+ console.error('Stripe webhook signature verification failed:', error)
49
+ return new NextResponse('Unauthorized', { status: 400 })
50
+ }
51
+
52
+ if (!RELEVANT_EVENTS.has(event.type)) {
53
+ return NextResponse.json({ received: true, ignored: true })
54
+ }
55
+
56
+ const existing = await db.query.stripeWebhooksEventsTable.findFirst({
57
+ where: eq(stripeWebhooksEventsTable.id, event.id)
58
+ })
59
+
60
+ if (existing?.processed) {
61
+ return NextResponse.json({ received: true, duplicate: true })
62
+ }
63
+
64
+ if (!existing) {
65
+ await db
66
+ .insert(stripeWebhooksEventsTable)
67
+ .values({ id: event.id, type: event.type })
68
+ .onConflictDoNothing()
69
+ }
70
+
71
+ try {
72
+ const customerId = extractCustomerId(event)
73
+ if (customerId) {
74
+ await syncSubscriptionFromStripe(customerId)
75
+ }
76
+
77
+ await db
78
+ .update(stripeWebhooksEventsTable)
79
+ .set({ processed: true, processedAt: new Date(), error: null })
80
+ .where(eq(stripeWebhooksEventsTable.id, event.id))
81
+
82
+ return NextResponse.json({ received: true, processed: true })
83
+ } catch (error) {
84
+ const message = error instanceof Error ? error.message : String(error)
85
+ console.error(`Failed to process Stripe event ${event.id}:`, error)
86
+
87
+ await db
88
+ .update(stripeWebhooksEventsTable)
89
+ .set({ error: message, payload: event as unknown })
90
+ .where(eq(stripeWebhooksEventsTable.id, event.id))
91
+
92
+ return new NextResponse('Webhook handler failed', { status: 500 })
93
+ }
94
+ }