create-ej-app 24.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 (161) hide show
  1. package/cli.js +6 -0
  2. package/cli.ts +8 -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 +20 -20
  7. package/template/nextjs/postcss.config.mjs +3 -1
  8. package/template/saas-starter/.dockerignore +7 -0
  9. package/template/saas-starter/.env.example +25 -0
  10. package/template/saas-starter/.vscode/extensions.json +11 -0
  11. package/template/saas-starter/.vscode/settings.json +3 -0
  12. package/template/saas-starter/Dockerfile +15 -0
  13. package/template/saas-starter/README.md +3 -0
  14. package/template/saas-starter/components.json +25 -0
  15. package/template/saas-starter/drizzle.config.ts +10 -0
  16. package/template/saas-starter/eslint.config.mjs +23 -0
  17. package/template/saas-starter/gitignore +9 -0
  18. package/template/saas-starter/next-env.d.ts +6 -0
  19. package/template/saas-starter/next.config.ts +14 -0
  20. package/template/saas-starter/package.json +92 -0
  21. package/template/saas-starter/postcss.config.mjs +7 -0
  22. package/template/saas-starter/prettier.config.mjs +11 -0
  23. package/template/saas-starter/public/placeholder.svg +1 -0
  24. package/template/saas-starter/src/actions/uploads.ts +28 -0
  25. package/template/saas-starter/src/actions/uploads.validation.ts +14 -0
  26. package/template/saas-starter/src/app/(admin)/actions/users.ts +97 -0
  27. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +21 -0
  28. package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  29. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +40 -0
  30. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +57 -0
  31. package/template/saas-starter/src/app/(admin)/admin/page.tsx +3 -0
  32. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +183 -0
  33. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +129 -0
  34. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +415 -0
  35. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +15 -0
  36. package/template/saas-starter/src/app/(app)/actions/billing.ts +152 -0
  37. package/template/saas-starter/src/app/(app)/actions/members.ts +163 -0
  38. package/template/saas-starter/src/app/(app)/actions/organization.ts +92 -0
  39. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +20 -0
  40. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +34 -0
  41. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +100 -0
  42. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +364 -0
  43. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +155 -0
  44. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +232 -0
  45. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +116 -0
  46. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +169 -0
  47. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +571 -0
  48. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +40 -0
  49. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +69 -0
  50. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +29 -0
  51. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +17 -0
  52. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +27 -0
  53. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +41 -0
  54. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +140 -0
  55. package/template/saas-starter/src/app/(app)/app/layout.tsx +52 -0
  56. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +129 -0
  57. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +42 -0
  58. package/template/saas-starter/src/app/(app)/app/page.tsx +81 -0
  59. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +109 -0
  60. package/template/saas-starter/src/app/(auth)/login/page.tsx +87 -0
  61. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +75 -0
  62. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +88 -0
  63. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +167 -0
  64. package/template/saas-starter/src/app/(marketing)/layout.tsx +14 -0
  65. package/template/saas-starter/src/app/(marketing)/page.tsx +3 -0
  66. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +3 -0
  67. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +3 -0
  68. package/template/saas-starter/src/app/api/auth/[...all]/route.ts +4 -0
  69. package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
  70. package/template/saas-starter/src/app/favicon.ico +0 -0
  71. package/template/saas-starter/src/app/globals.css +240 -0
  72. package/template/saas-starter/src/app/layout.tsx +53 -0
  73. package/template/saas-starter/src/app/not-found.tsx +27 -0
  74. package/template/saas-starter/src/app/proxy.ts +28 -0
  75. package/template/saas-starter/src/app/robots.ts +32 -0
  76. package/template/saas-starter/src/app/sitemap.ts +20 -0
  77. package/template/saas-starter/src/components/dashboard-header.tsx +64 -0
  78. package/template/saas-starter/src/components/data-table/enum.ts +5 -0
  79. package/template/saas-starter/src/components/data-table/index.tsx +960 -0
  80. package/template/saas-starter/src/components/data-table/types.ts +75 -0
  81. package/template/saas-starter/src/components/error.tsx +26 -0
  82. package/template/saas-starter/src/components/file-upload.tsx +215 -0
  83. package/template/saas-starter/src/components/impersonation-banner.tsx +44 -0
  84. package/template/saas-starter/src/components/loader.tsx +25 -0
  85. package/template/saas-starter/src/components/navigation-search.tsx +140 -0
  86. package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
  87. package/template/saas-starter/src/components/page-heading.tsx +32 -0
  88. package/template/saas-starter/src/components/react-query-provider.tsx +12 -0
  89. package/template/saas-starter/src/components/screen-size.tsx +40 -0
  90. package/template/saas-starter/src/components/site-footer.tsx +23 -0
  91. package/template/saas-starter/src/components/theme-provider.tsx +67 -0
  92. package/template/saas-starter/src/components/theme-toggle.tsx +22 -0
  93. package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
  94. package/template/saas-starter/src/components/ui/badge.tsx +49 -0
  95. package/template/saas-starter/src/components/ui/button.tsx +67 -0
  96. package/template/saas-starter/src/components/ui/card.tsx +103 -0
  97. package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
  98. package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
  99. package/template/saas-starter/src/components/ui/command.tsx +192 -0
  100. package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
  101. package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
  102. package/template/saas-starter/src/components/ui/field.tsx +238 -0
  103. package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
  104. package/template/saas-starter/src/components/ui/input.tsx +19 -0
  105. package/template/saas-starter/src/components/ui/label.tsx +24 -0
  106. package/template/saas-starter/src/components/ui/popover.tsx +89 -0
  107. package/template/saas-starter/src/components/ui/select.tsx +195 -0
  108. package/template/saas-starter/src/components/ui/separator.tsx +28 -0
  109. package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
  110. package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
  111. package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
  112. package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
  113. package/template/saas-starter/src/components/ui/switch.tsx +33 -0
  114. package/template/saas-starter/src/components/ui/table.tsx +116 -0
  115. package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
  116. package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
  117. package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
  118. package/template/saas-starter/src/components/user-menu.tsx +90 -0
  119. package/template/saas-starter/src/db/common.ts +31 -0
  120. package/template/saas-starter/src/db/enums.ts +26 -0
  121. package/template/saas-starter/src/db/index.ts +51 -0
  122. package/template/saas-starter/src/db/relations.ts +75 -0
  123. package/template/saas-starter/src/db/schema.ts +191 -0
  124. package/template/saas-starter/src/emails/components/email-layout.tsx +63 -0
  125. package/template/saas-starter/src/emails/invitation.tsx +56 -0
  126. package/template/saas-starter/src/emails/magic-link.tsx +49 -0
  127. package/template/saas-starter/src/emails/test.tsx +32 -0
  128. package/template/saas-starter/src/emails/welcome.tsx +34 -0
  129. package/template/saas-starter/src/env.ts +66 -0
  130. package/template/saas-starter/src/hooks/use-mobile.ts +19 -0
  131. package/template/saas-starter/src/instrumentation.ts +26 -0
  132. package/template/saas-starter/src/lib/actions.ts +21 -0
  133. package/template/saas-starter/src/lib/admin-navigation.ts +31 -0
  134. package/template/saas-starter/src/lib/app-navigation.ts +59 -0
  135. package/template/saas-starter/src/lib/auth-client.ts +29 -0
  136. package/template/saas-starter/src/lib/auth-permissions.ts +22 -0
  137. package/template/saas-starter/src/lib/auth.ts +211 -0
  138. package/template/saas-starter/src/lib/billing.ts +117 -0
  139. package/template/saas-starter/src/lib/cn.ts +4 -0
  140. package/template/saas-starter/src/lib/constants.ts +22 -0
  141. package/template/saas-starter/src/lib/delay.ts +2 -0
  142. package/template/saas-starter/src/lib/email-service.ts +89 -0
  143. package/template/saas-starter/src/lib/format-currency.ts +27 -0
  144. package/template/saas-starter/src/lib/format-date.ts +12 -0
  145. package/template/saas-starter/src/lib/nodemailer.ts +43 -0
  146. package/template/saas-starter/src/lib/organization-access.ts +93 -0
  147. package/template/saas-starter/src/lib/organization-plan-limits.ts +38 -0
  148. package/template/saas-starter/src/lib/otel.ts +27 -0
  149. package/template/saas-starter/src/lib/plans.ts +63 -0
  150. package/template/saas-starter/src/lib/safe-action-client.ts +75 -0
  151. package/template/saas-starter/src/lib/safe-action.ts +67 -0
  152. package/template/saas-starter/src/lib/siteConfig.ts +4 -0
  153. package/template/saas-starter/src/lib/storage.ts +181 -0
  154. package/template/saas-starter/src/lib/stripe.ts +6 -0
  155. package/template/saas-starter/src/lib/toast-message.ts +15 -0
  156. package/template/saas-starter/src/lib/validations.ts +12 -0
  157. package/template/saas-starter/src/scripts/reset-db.ts +22 -0
  158. package/template/saas-starter/src/scripts/seed.ts +11 -0
  159. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +200 -0
  160. package/template/saas-starter/src/scripts/test-mail.ts +35 -0
  161. package/template/saas-starter/tsconfig.json +51 -0
@@ -0,0 +1,191 @@
1
+ import { sql } from 'drizzle-orm'
2
+ import {
3
+ boolean,
4
+ index,
5
+ integer,
6
+ jsonb,
7
+ pgTable,
8
+ text,
9
+ uniqueIndex,
10
+ varchar
11
+ } from 'drizzle-orm/pg-core'
12
+ import { commonFieldDefs } from './common'
13
+ import {
14
+ organizationSubscriptionIntervalEnum,
15
+ organizationSubscriptionPlanEnum,
16
+ organizationSubscriptionStatusEnum
17
+ } from './enums'
18
+
19
+ export * from './enums'
20
+
21
+ export const usersTable = pgTable('users', {
22
+ id: commonFieldDefs.id('user'),
23
+ name: text('name').notNull(),
24
+ email: text('email').notNull().unique(),
25
+ emailVerified: boolean('email_verified').default(false).notNull(),
26
+ image: text('image'),
27
+ role: text('role'),
28
+ banned: boolean('banned').default(false),
29
+ banReason: text('ban_reason'),
30
+ banExpires: commonFieldDefs.date('ban_expires'),
31
+ ...commonFieldDefs.dates
32
+ })
33
+
34
+ export const sessionsTable = pgTable(
35
+ 'sessions',
36
+ {
37
+ id: commonFieldDefs.id('session'),
38
+ expiresAt: commonFieldDefs.date('expires_at').notNull(),
39
+ token: text('token').notNull().unique(),
40
+ ipAddress: text('ip_address'),
41
+ userAgent: text('user_agent'),
42
+ userId: text('user_id')
43
+ .notNull()
44
+ .references(() => usersTable.id, { onDelete: 'cascade' }),
45
+ impersonatedBy: text('impersonated_by'),
46
+ activeOrganizationId: text('active_organization_id'),
47
+ ...commonFieldDefs.dates
48
+ },
49
+ table => [index().on(table.userId)]
50
+ )
51
+
52
+ export const accountsTable = pgTable(
53
+ 'accounts',
54
+ {
55
+ id: commonFieldDefs.id('account'),
56
+ accountId: text('account_id').notNull(),
57
+ providerId: text('provider_id').notNull(),
58
+ userId: text('user_id')
59
+ .notNull()
60
+ .references(() => usersTable.id, { onDelete: 'cascade' }),
61
+ accessToken: text('access_token'),
62
+ refreshToken: text('refresh_token'),
63
+ idToken: text('id_token'),
64
+ accessTokenExpiresAt: commonFieldDefs.date('access_token_expires_at'),
65
+ refreshTokenExpiresAt: commonFieldDefs.date('refresh_token_expires_at'),
66
+ scope: text('scope'),
67
+ password: text('password'),
68
+ ...commonFieldDefs.dates
69
+ },
70
+ table => [index().on(table.userId)]
71
+ )
72
+
73
+ export const verificationsTable = pgTable('verifications', {
74
+ id: commonFieldDefs.id('verification'),
75
+ identifier: text('identifier').notNull(),
76
+ value: text('value').notNull(),
77
+ expiresAt: commonFieldDefs.date('expires_at').notNull(),
78
+ ...commonFieldDefs.dates
79
+ })
80
+
81
+ export const organizationsTable = pgTable('organizations', {
82
+ id: commonFieldDefs.id('organization'),
83
+ name: text('name').notNull(),
84
+ slug: text('slug').notNull().unique(),
85
+ logo: text('logo'),
86
+ metadata: text('metadata'),
87
+ ...commonFieldDefs.dates
88
+ })
89
+
90
+ export const membersTable = pgTable(
91
+ 'members',
92
+ {
93
+ id: commonFieldDefs.id('member'),
94
+ organizationId: text('organization_id')
95
+ .notNull()
96
+ .references(() => organizationsTable.id, { onDelete: 'cascade' }),
97
+ userId: text('user_id')
98
+ .notNull()
99
+ .references(() => usersTable.id, { onDelete: 'cascade' }),
100
+ role: text('role').notNull(),
101
+ ...commonFieldDefs.dates
102
+ },
103
+ table => [index().on(table.organizationId), index().on(table.userId)]
104
+ )
105
+
106
+ export const invitationsTable = pgTable(
107
+ 'invitations',
108
+ {
109
+ id: commonFieldDefs.id('invitation'),
110
+ organizationId: text('organization_id')
111
+ .notNull()
112
+ .references(() => organizationsTable.id, { onDelete: 'cascade' }),
113
+ email: text('email').notNull(),
114
+ role: text('role'),
115
+ status: text('status').default('pending').notNull(),
116
+ expiresAt: commonFieldDefs.date('expires_at'),
117
+ inviterId: text('inviter_id')
118
+ .notNull()
119
+ .references(() => usersTable.id, {
120
+ onDelete: 'cascade'
121
+ }),
122
+ ...commonFieldDefs.dates
123
+ },
124
+ table => [
125
+ index().on(table.organizationId),
126
+ index().on(table.email),
127
+ uniqueIndex()
128
+ .on(table.organizationId, table.email)
129
+ .where(sql`${table.status} = 'pending'`)
130
+ ]
131
+ )
132
+
133
+ export const organizationSubscriptionsTable = pgTable(
134
+ 'organization_subscriptions',
135
+ {
136
+ id: commonFieldDefs.id('organization_subscription'),
137
+ organizationId: text('organization_id')
138
+ .notNull()
139
+ .unique()
140
+ .references(() => organizationsTable.id),
141
+ plan: organizationSubscriptionPlanEnum('plan').notNull().default('free'),
142
+ status: organizationSubscriptionStatusEnum('status')
143
+ .notNull()
144
+ .default('free'),
145
+ interval: organizationSubscriptionIntervalEnum('interval'),
146
+ billingEmail: text('billing_email'),
147
+ stripeCustomerId: text('stripe_customer_id').unique(),
148
+ stripeSubscriptionId: text('stripe_subscription_id').unique(),
149
+ hasUsedTrial: boolean('has_used_trial').notNull().default(false),
150
+ trialEndsAt: commonFieldDefs.date('trial_ends_at'),
151
+ currentPeriodEnd: commonFieldDefs.date('current_period_end'),
152
+ cancelAtPeriodEnd: boolean('cancel_at_period_end').notNull().default(false),
153
+ ...commonFieldDefs.dates
154
+ }
155
+ )
156
+
157
+ export const stripeWebhooksEventsTable = pgTable('stripe_webhooks_events', {
158
+ id: text('id').primaryKey(),
159
+ type: text('type').notNull(),
160
+ receivedAt: commonFieldDefs.date('received_at').notNull().defaultNow(),
161
+ processed: boolean('processed').notNull().default(false),
162
+ processedAt: commonFieldDefs.date('processed_at'),
163
+ error: text('error'),
164
+ payload: jsonb('payload') // store only on error
165
+ })
166
+
167
+ export const fileUploadsTable = pgTable(
168
+ 'file_uploads',
169
+ {
170
+ id: varchar('id').primaryKey(),
171
+ // Object path e.g. uploads/organizations/organizationId/filename.jpg
172
+ key: text('key').notNull().unique(),
173
+ originalName: varchar('original_name').notNull(),
174
+ mimeType: varchar('mime_type').notNull(),
175
+ // In Bytes
176
+ size: integer('size').notNull(),
177
+ isTemp: boolean('is_temp').notNull().default(true),
178
+ uploadedBy: varchar('uploaded_by').references(() => usersTable.id, {
179
+ onDelete: 'set null'
180
+ }),
181
+ // Nullable: scopes upload to a organization for cleanup purposes
182
+ organizationId: varchar('organization_id').references(
183
+ () => organizationsTable.id
184
+ ),
185
+ ...commonFieldDefs.dates
186
+ },
187
+ table => [
188
+ index().on(table.uploadedBy),
189
+ index().on(table.isTemp, table.createdAt)
190
+ ]
191
+ )
@@ -0,0 +1,63 @@
1
+ import {
2
+ Body,
3
+ Container,
4
+ Head,
5
+ Hr,
6
+ Html,
7
+ pixelBasedPreset,
8
+ Preview,
9
+ Section,
10
+ Tailwind,
11
+ Text
12
+ } from 'react-email'
13
+
14
+ interface EmailLayoutProps {
15
+ previewText: string
16
+ companyName: string
17
+ children: React.ReactNode
18
+ }
19
+
20
+ export default function EmailLayout({
21
+ previewText,
22
+ companyName,
23
+ children
24
+ }: EmailLayoutProps) {
25
+ return (
26
+ <Html>
27
+ <Head />
28
+ <Preview>{previewText}</Preview>
29
+ <Tailwind
30
+ config={{
31
+ presets: [pixelBasedPreset]
32
+ }}
33
+ >
34
+ <Body className="bg-gray-100 font-sans">
35
+ <Container className="mx-auto my-8 max-w-xl rounded-lg bg-white shadow-sm">
36
+ {/* Header */}
37
+ <Section className="rounded-t-lg bg-black px-8 py-6">
38
+ <Text className="m-0 text-center text-2xl font-bold text-white">
39
+ {companyName}
40
+ </Text>
41
+ </Section>
42
+
43
+ {/* Content */}
44
+ <Section className="px-8 py-6">{children}</Section>
45
+
46
+ {/* Footer */}
47
+ <Section className="rounded-b-lg bg-gray-50 px-8 py-6">
48
+ <Hr className="my-4 border-gray-200" />
49
+ <Text className="m-0 text-center text-sm text-gray-500">
50
+ &copy; {new Date().getUTCFullYear()} {companyName}. All rights
51
+ reserved.
52
+ </Text>
53
+ <Text className="m-0 mt-2 text-center text-xs text-gray-400">
54
+ This is an automated email. Please do not reply directly to this
55
+ message.
56
+ </Text>
57
+ </Section>
58
+ </Container>
59
+ </Body>
60
+ </Tailwind>
61
+ </Html>
62
+ )
63
+ }
@@ -0,0 +1,56 @@
1
+ import { Button, Heading, Text } from 'react-email'
2
+ import EmailLayout from './components/email-layout'
3
+
4
+ export interface InvitationEmailProps {
5
+ inviterName: string
6
+ organizationName: string
7
+ inviteLink: string
8
+ companyName: string
9
+ }
10
+
11
+ export default function InvitationEmail({
12
+ inviterName,
13
+ organizationName,
14
+ inviteLink,
15
+ companyName
16
+ }: InvitationEmailProps) {
17
+ const previewText = `${inviterName} invited you to join ${organizationName}`
18
+
19
+ return (
20
+ <EmailLayout previewText={previewText} companyName={companyName}>
21
+ <Heading className="mb-4 text-2xl font-bold text-gray-900">
22
+ You've been invited to join {organizationName}
23
+ </Heading>
24
+
25
+ <Text className="mb-4 text-base text-gray-700">
26
+ <strong>{inviterName}</strong> has invited you to collaborate in{' '}
27
+ <strong>{organizationName}</strong> on {companyName}.
28
+ </Text>
29
+
30
+ <Button
31
+ href={inviteLink}
32
+ className="mb-4 rounded-lg bg-black px-6 py-3 text-center text-base font-semibold text-white no-underline"
33
+ >
34
+ Accept Invitation
35
+ </Button>
36
+
37
+ <Text className="mb-4 text-sm text-gray-600">
38
+ Or copy and paste this link into your browser:
39
+ </Text>
40
+
41
+ <Text className="mb-4 text-sm break-all text-blue-600">{inviteLink}</Text>
42
+
43
+ <Text className="text-sm text-gray-500">
44
+ If you weren't expecting this invitation, you can safely ignore this
45
+ email.
46
+ </Text>
47
+ </EmailLayout>
48
+ )
49
+ }
50
+
51
+ InvitationEmail.PreviewProps = {
52
+ inviterName: 'John Doe',
53
+ organizationName: 'Acme Inc',
54
+ inviteLink: 'https://example.com/accept-invitation/abc123',
55
+ companyName: 'Example Site'
56
+ } satisfies InvitationEmailProps
@@ -0,0 +1,49 @@
1
+ import { Button, Heading, Text } from 'react-email'
2
+ import EmailLayout from './components/email-layout'
3
+
4
+ export interface MagicLinkEmailProps {
5
+ magicLink: string
6
+ companyName: string
7
+ }
8
+
9
+ export default function MagicLinkEmail({
10
+ magicLink,
11
+ companyName
12
+ }: MagicLinkEmailProps) {
13
+ const previewText = `Your magic link to sign in to ${companyName}`
14
+
15
+ return (
16
+ <EmailLayout previewText={previewText} companyName={companyName}>
17
+ <Heading className="mb-4 text-2xl font-bold text-gray-900">
18
+ Sign in to {companyName}
19
+ </Heading>
20
+
21
+ <Text className="mb-4 text-base text-gray-700">
22
+ Click the button below to securely sign in to your account. This link
23
+ will expire in 5 minutes.
24
+ </Text>
25
+
26
+ <Button
27
+ href={magicLink}
28
+ className="mb-4 rounded-lg bg-black px-6 py-3 text-center text-base font-semibold text-white no-underline"
29
+ >
30
+ Sign In
31
+ </Button>
32
+
33
+ <Text className="mb-4 text-sm text-gray-600">
34
+ Or copy and paste this link into your browser:
35
+ </Text>
36
+
37
+ <Text className="mb-4 text-sm break-all text-blue-600">{magicLink}</Text>
38
+
39
+ <Text className="text-sm text-gray-500">
40
+ If you didn't request this email, you can safely ignore it.
41
+ </Text>
42
+ </EmailLayout>
43
+ )
44
+ }
45
+
46
+ MagicLinkEmail.PreviewProps = {
47
+ magicLink: 'https://example.com/magic-link?token=abc123',
48
+ companyName: 'Example Site'
49
+ } satisfies MagicLinkEmailProps
@@ -0,0 +1,32 @@
1
+ import { Button, Heading, Text } from 'react-email'
2
+ import EmailLayout from './components/email-layout'
3
+
4
+ interface TestEmailProps {
5
+ name: string
6
+ }
7
+
8
+ export default function TestEmail({ name }: TestEmailProps) {
9
+ return (
10
+ <EmailLayout previewText="This is a test email" companyName="Test">
11
+ <Heading className="mb-4 text-2xl font-bold text-gray-900">
12
+ Test Email for {name}
13
+ </Heading>
14
+ <Text className="mb-4 text-base text-gray-700">
15
+ This is a test email template to verify email functionality.
16
+ </Text>
17
+ <Text className="mb-6 text-base text-gray-700">
18
+ If you're seeing this, the email system is working correctly.
19
+ </Text>
20
+ <Button
21
+ href="https://example.com"
22
+ className="rounded-md bg-black px-5 py-3 text-center text-sm font-medium text-white"
23
+ >
24
+ Test Button
25
+ </Button>
26
+ </EmailLayout>
27
+ )
28
+ }
29
+
30
+ TestEmail.PreviewProps = {
31
+ name: 'John Doe'
32
+ }
@@ -0,0 +1,34 @@
1
+ import { Heading, Text } from 'react-email'
2
+ import EmailLayout from './components/email-layout'
3
+
4
+ export interface WelcomeEmailProps {
5
+ userName: string
6
+ companyName: string
7
+ }
8
+
9
+ export default function WelcomeEmail({
10
+ userName,
11
+ companyName
12
+ }: WelcomeEmailProps) {
13
+ const previewText = `Welcome to ${companyName}! Your account has been created successfully.`
14
+
15
+ return (
16
+ <EmailLayout previewText={previewText} companyName={companyName}>
17
+ <Heading className="mb-4 text-2xl font-bold text-gray-900">
18
+ Welcome to {companyName}! 🎉
19
+ </Heading>
20
+
21
+ <Text className="mb-4 text-base text-gray-700">Hi {userName},</Text>
22
+
23
+ <Text className="mb-4 text-base text-gray-700">
24
+ Thank you for creating an account with us. We're thrilled to have you as
25
+ part of our community!
26
+ </Text>
27
+ </EmailLayout>
28
+ )
29
+ }
30
+
31
+ WelcomeEmail.PreviewProps = {
32
+ userName: 'John Doe',
33
+ companyName: 'Example Site'
34
+ } satisfies WelcomeEmailProps
@@ -0,0 +1,66 @@
1
+ import { createEnv } from '@t3-oss/env-nextjs'
2
+ import { z } from 'zod'
3
+
4
+ export const env = createEnv({
5
+ server: {
6
+ APP_ENV: z.enum(['development', 'staging', 'production']),
7
+ ANALYZE: z.string().optional(),
8
+ DATABASE_URL: z.url(),
9
+ BETTER_AUTH_URL: z.url(),
10
+ BETTER_AUTH_SECRET: z.string(),
11
+ BETTER_AUTH_GITHUB_ID: z.string(),
12
+ BETTER_AUTH_GITHUB_SECRET: z.string(),
13
+ BETTER_AUTH_GOOGLE_ID: z.string(),
14
+ BETTER_AUTH_GOOGLE_SECRET: z.string(),
15
+ EMAIL_SERVER_USER: z.string(),
16
+ EMAIL_SERVER_PASSWORD: z.string(),
17
+ EMAIL_SERVER_HOST: z.string(),
18
+ EMAIL_SERVER_PORT: z.string(),
19
+ EMAIL_FROM: z.email(),
20
+ STRIPE_SECRET_KEY: z.string(),
21
+ STRIPE_WEBHOOK_SECRET: z.string(),
22
+ STRIPE_PRICE_PRO_MONTHLY: z.string(),
23
+ STRIPE_PRICE_PRO_ANNUAL: z.string(),
24
+ STRIPE_PRICE_ULTIMATE_MONTHLY: z.string(),
25
+ STRIPE_PRICE_ULTIMATE_ANNUAL: z.string(),
26
+ STRIPE_PORTAL_CONFIGURATION_ID: z.string(),
27
+ R2_ACCOUNT_ID: z.string().optional(),
28
+ R2_ACCESS_KEY_ID: z.string().optional(),
29
+ R2_SECRET_ACCESS_KEY: z.string().optional(),
30
+ R2_BUCKET_NAME: z.string().optional(),
31
+ R2_PUBLIC_URL: z.url().optional()
32
+ },
33
+
34
+ client: {},
35
+
36
+ runtimeEnv: {
37
+ APP_ENV: process.env.APP_ENV,
38
+ ANALYZE: process.env.ANALYZE,
39
+ DATABASE_URL: process.env.DATABASE_URL,
40
+ BETTER_AUTH_URL: process.env.BETTER_AUTH_URL,
41
+ BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
42
+ BETTER_AUTH_GITHUB_ID: process.env.BETTER_AUTH_GITHUB_ID,
43
+ BETTER_AUTH_GITHUB_SECRET: process.env.BETTER_AUTH_GITHUB_SECRET,
44
+ BETTER_AUTH_GOOGLE_ID: process.env.BETTER_AUTH_GOOGLE_ID,
45
+ BETTER_AUTH_GOOGLE_SECRET: process.env.BETTER_AUTH_GOOGLE_SECRET,
46
+ EMAIL_SERVER_USER: process.env.EMAIL_SERVER_USER,
47
+ EMAIL_SERVER_PASSWORD: process.env.EMAIL_SERVER_PASSWORD,
48
+ EMAIL_SERVER_HOST: process.env.EMAIL_SERVER_HOST,
49
+ EMAIL_SERVER_PORT: process.env.EMAIL_SERVER_PORT,
50
+ EMAIL_FROM: process.env.EMAIL_FROM,
51
+ STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
52
+ STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
53
+ STRIPE_PRICE_PRO_MONTHLY: process.env.STRIPE_PRICE_PRO_MONTHLY,
54
+ STRIPE_PRICE_PRO_ANNUAL: process.env.STRIPE_PRICE_PRO_ANNUAL,
55
+ STRIPE_PRICE_ULTIMATE_MONTHLY: process.env.STRIPE_PRICE_ULTIMATE_MONTHLY,
56
+ STRIPE_PRICE_ULTIMATE_ANNUAL: process.env.STRIPE_PRICE_ULTIMATE_ANNUAL,
57
+ STRIPE_PORTAL_CONFIGURATION_ID: process.env.STRIPE_PORTAL_CONFIGURATION_ID,
58
+ R2_ACCOUNT_ID: process.env.R2_ACCOUNT_ID,
59
+ R2_ACCESS_KEY_ID: process.env.R2_ACCESS_KEY_ID,
60
+ R2_SECRET_ACCESS_KEY: process.env.R2_SECRET_ACCESS_KEY,
61
+ R2_BUCKET_NAME: process.env.R2_BUCKET_NAME,
62
+ R2_PUBLIC_URL: process.env.R2_PUBLIC_URL
63
+ },
64
+
65
+ emptyStringAsUndefined: true
66
+ })
@@ -0,0 +1,19 @@
1
+ import * as React from 'react'
2
+
3
+ const MOBILE_BREAKPOINT = 768
4
+
5
+ export function useIsMobile() {
6
+ const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
7
+
8
+ React.useEffect(() => {
9
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
10
+ const onChange = () => {
11
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
12
+ }
13
+ mql.addEventListener('change', onChange)
14
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
15
+ return () => mql.removeEventListener('change', onChange)
16
+ }, [])
17
+
18
+ return !!isMobile
19
+ }
@@ -0,0 +1,26 @@
1
+ import { SpanStatusCode } from '@opentelemetry/api'
2
+ import {
3
+ ConsoleSpanExporter,
4
+ ReadableSpan,
5
+ SimpleSpanProcessor
6
+ } from '@opentelemetry/sdk-trace-base'
7
+ import { registerOTel } from '@vercel/otel'
8
+
9
+ class SmartSamplingSpanProcessor extends SimpleSpanProcessor {
10
+ onEnd(span: ReadableSpan): void {
11
+ const isError = span.status.code === SpanStatusCode.ERROR
12
+ const isSlow = span.duration[0] * 1000 + span.duration[1] / 1e6 > 5000
13
+ const isLucky = Math.random() < 0 // Adjust this value to adjust the sampling rate for non-error and non-slow spans
14
+
15
+ if (isError || isSlow || isLucky) {
16
+ super.onEnd(span)
17
+ }
18
+ }
19
+ }
20
+
21
+ export function register() {
22
+ registerOTel({
23
+ serviceName: 'my-nextjs-app',
24
+ spanProcessors: [new SmartSamplingSpanProcessor(new ConsoleSpanExporter())]
25
+ })
26
+ }
@@ -0,0 +1,21 @@
1
+ import { z } from 'zod'
2
+ import { SortOrderEnum } from '~/components/data-table/enum'
3
+
4
+ export const getPaginationSchema = (defaultPageSize = 10) => ({
5
+ page: z.coerce.number().int().positive().optional().default(1),
6
+ limit: z.coerce
7
+ .number()
8
+ .int()
9
+ .positive()
10
+ .min(10)
11
+ .max(100)
12
+ .optional()
13
+ .default(defaultPageSize)
14
+ })
15
+
16
+ export const getSortSchema = <T extends string>(
17
+ keys: readonly [T, ...T[]]
18
+ ) => ({
19
+ sortOrder: z.enum(SortOrderEnum).optional(),
20
+ sortBy: z.enum(keys).optional()
21
+ })
@@ -0,0 +1,31 @@
1
+ import { Home, Users } from 'lucide-react'
2
+ import { SidebarNavGroup } from '~/components/navigation-sidebar'
3
+
4
+ const adminNavigation: SidebarNavGroup[] = [
5
+ {
6
+ label: 'Overview',
7
+ items: [
8
+ {
9
+ title: 'Dashboard',
10
+ url: '/admin',
11
+ exact: true,
12
+ icon: Home
13
+ }
14
+ ]
15
+ },
16
+ {
17
+ label: 'Settings',
18
+ items: [
19
+ {
20
+ title: 'Users & Roles',
21
+ url: '/admin/settings/users',
22
+ icon: Users
23
+ }
24
+ ]
25
+ }
26
+ ]
27
+
28
+ export const getAdminNavigation = (isSuperAdmin: boolean): SidebarNavGroup[] =>
29
+ isSuperAdmin
30
+ ? adminNavigation
31
+ : adminNavigation.filter(group => !group.superadminOnly)
@@ -0,0 +1,59 @@
1
+ import { CreditCard, Home, Settings, Users } from 'lucide-react'
2
+ import { SidebarNavGroup } from '~/components/navigation-sidebar'
3
+
4
+ export type UserOrganization = {
5
+ id: string
6
+ name: string
7
+ slug: string
8
+ logo: string | null
9
+ role: string
10
+ userId: string
11
+ memberId: string
12
+ }
13
+
14
+ export const canManageOrganization = (role: string): boolean =>
15
+ role === 'owner' || role === 'admin'
16
+
17
+ export const getAppNavigation = (
18
+ orgId: string,
19
+ role: string
20
+ ): SidebarNavGroup[] => {
21
+ const groups: SidebarNavGroup[] = [
22
+ {
23
+ label: 'Overview',
24
+ items: [
25
+ {
26
+ title: 'Dashboard',
27
+ url: `/app/${orgId}/dashboard`,
28
+ icon: Home
29
+ }
30
+ ]
31
+ }
32
+ ]
33
+
34
+ if (canManageOrganization(role)) {
35
+ groups.push({
36
+ label: 'Settings',
37
+ items: [
38
+ {
39
+ title: 'General',
40
+ url: `/app/${orgId}/settings`,
41
+ icon: Settings,
42
+ exact: true
43
+ },
44
+ {
45
+ title: 'Members',
46
+ url: `/app/${orgId}/settings/members`,
47
+ icon: Users
48
+ },
49
+ {
50
+ title: 'Billing',
51
+ url: `/app/${orgId}/settings/billing`,
52
+ icon: CreditCard
53
+ }
54
+ ]
55
+ })
56
+ }
57
+
58
+ return groups
59
+ }
@@ -0,0 +1,29 @@
1
+ 'use client'
2
+
3
+ import {
4
+ adminClient,
5
+ inferAdditionalFields,
6
+ lastLoginMethodClient,
7
+ magicLinkClient,
8
+ organizationClient
9
+ } from 'better-auth/client/plugins'
10
+ import { createAuthClient } from 'better-auth/react'
11
+ import type { auth } from './auth'
12
+
13
+ export const {
14
+ signIn,
15
+ signOut,
16
+ useSession,
17
+ getLastUsedLoginMethod,
18
+ admin,
19
+ organization,
20
+ useListOrganizations
21
+ } = createAuthClient({
22
+ plugins: [
23
+ inferAdditionalFields<typeof auth>(),
24
+ magicLinkClient(),
25
+ adminClient(),
26
+ organizationClient(),
27
+ lastLoginMethodClient()
28
+ ]
29
+ })