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.
- package/cli.js +6 -0
- package/cli.ts +8 -0
- package/package.json +2 -2
- package/template/api/package.json +2 -2
- package/template/nextjs/gitignore +1 -0
- package/template/nextjs/package.json +20 -20
- package/template/nextjs/postcss.config.mjs +3 -1
- package/template/saas-starter/.dockerignore +7 -0
- package/template/saas-starter/.env.example +25 -0
- package/template/saas-starter/.vscode/extensions.json +11 -0
- package/template/saas-starter/.vscode/settings.json +3 -0
- package/template/saas-starter/Dockerfile +15 -0
- package/template/saas-starter/README.md +3 -0
- package/template/saas-starter/components.json +25 -0
- package/template/saas-starter/drizzle.config.ts +10 -0
- package/template/saas-starter/eslint.config.mjs +23 -0
- package/template/saas-starter/gitignore +9 -0
- package/template/saas-starter/next-env.d.ts +6 -0
- package/template/saas-starter/next.config.ts +14 -0
- package/template/saas-starter/package.json +92 -0
- package/template/saas-starter/postcss.config.mjs +7 -0
- package/template/saas-starter/prettier.config.mjs +11 -0
- package/template/saas-starter/public/placeholder.svg +1 -0
- package/template/saas-starter/src/actions/uploads.ts +28 -0
- package/template/saas-starter/src/actions/uploads.validation.ts +14 -0
- package/template/saas-starter/src/app/(admin)/actions/users.ts +97 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +21 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
- package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +40 -0
- package/template/saas-starter/src/app/(admin)/admin/layout.tsx +57 -0
- package/template/saas-starter/src/app/(admin)/admin/page.tsx +3 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +183 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +129 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +415 -0
- package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +15 -0
- package/template/saas-starter/src/app/(app)/actions/billing.ts +152 -0
- package/template/saas-starter/src/app/(app)/actions/members.ts +163 -0
- package/template/saas-starter/src/app/(app)/actions/organization.ts +92 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +20 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +34 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +100 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +364 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +155 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +232 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +116 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +169 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +571 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +40 -0
- package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +69 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +29 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +17 -0
- package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +27 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +41 -0
- package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +140 -0
- package/template/saas-starter/src/app/(app)/app/layout.tsx +52 -0
- package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +129 -0
- package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +42 -0
- package/template/saas-starter/src/app/(app)/app/page.tsx +81 -0
- package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +109 -0
- package/template/saas-starter/src/app/(auth)/login/page.tsx +87 -0
- package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +75 -0
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +88 -0
- package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +167 -0
- package/template/saas-starter/src/app/(marketing)/layout.tsx +14 -0
- package/template/saas-starter/src/app/(marketing)/page.tsx +3 -0
- package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +3 -0
- package/template/saas-starter/src/app/(marketing)/terms/page.tsx +3 -0
- package/template/saas-starter/src/app/api/auth/[...all]/route.ts +4 -0
- package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
- package/template/saas-starter/src/app/favicon.ico +0 -0
- package/template/saas-starter/src/app/globals.css +240 -0
- package/template/saas-starter/src/app/layout.tsx +53 -0
- package/template/saas-starter/src/app/not-found.tsx +27 -0
- package/template/saas-starter/src/app/proxy.ts +28 -0
- package/template/saas-starter/src/app/robots.ts +32 -0
- package/template/saas-starter/src/app/sitemap.ts +20 -0
- package/template/saas-starter/src/components/dashboard-header.tsx +64 -0
- package/template/saas-starter/src/components/data-table/enum.ts +5 -0
- package/template/saas-starter/src/components/data-table/index.tsx +960 -0
- package/template/saas-starter/src/components/data-table/types.ts +75 -0
- package/template/saas-starter/src/components/error.tsx +26 -0
- package/template/saas-starter/src/components/file-upload.tsx +215 -0
- package/template/saas-starter/src/components/impersonation-banner.tsx +44 -0
- package/template/saas-starter/src/components/loader.tsx +25 -0
- package/template/saas-starter/src/components/navigation-search.tsx +140 -0
- package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
- package/template/saas-starter/src/components/page-heading.tsx +32 -0
- package/template/saas-starter/src/components/react-query-provider.tsx +12 -0
- package/template/saas-starter/src/components/screen-size.tsx +40 -0
- package/template/saas-starter/src/components/site-footer.tsx +23 -0
- package/template/saas-starter/src/components/theme-provider.tsx +67 -0
- package/template/saas-starter/src/components/theme-toggle.tsx +22 -0
- package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
- package/template/saas-starter/src/components/ui/badge.tsx +49 -0
- package/template/saas-starter/src/components/ui/button.tsx +67 -0
- package/template/saas-starter/src/components/ui/card.tsx +103 -0
- package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
- package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
- package/template/saas-starter/src/components/ui/command.tsx +192 -0
- package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
- package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
- package/template/saas-starter/src/components/ui/field.tsx +238 -0
- package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
- package/template/saas-starter/src/components/ui/input.tsx +19 -0
- package/template/saas-starter/src/components/ui/label.tsx +24 -0
- package/template/saas-starter/src/components/ui/popover.tsx +89 -0
- package/template/saas-starter/src/components/ui/select.tsx +195 -0
- package/template/saas-starter/src/components/ui/separator.tsx +28 -0
- package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
- package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
- package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
- package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
- package/template/saas-starter/src/components/ui/switch.tsx +33 -0
- package/template/saas-starter/src/components/ui/table.tsx +116 -0
- package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
- package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
- package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
- package/template/saas-starter/src/components/user-menu.tsx +90 -0
- package/template/saas-starter/src/db/common.ts +31 -0
- package/template/saas-starter/src/db/enums.ts +26 -0
- package/template/saas-starter/src/db/index.ts +51 -0
- package/template/saas-starter/src/db/relations.ts +75 -0
- package/template/saas-starter/src/db/schema.ts +191 -0
- package/template/saas-starter/src/emails/components/email-layout.tsx +63 -0
- package/template/saas-starter/src/emails/invitation.tsx +56 -0
- package/template/saas-starter/src/emails/magic-link.tsx +49 -0
- package/template/saas-starter/src/emails/test.tsx +32 -0
- package/template/saas-starter/src/emails/welcome.tsx +34 -0
- package/template/saas-starter/src/env.ts +66 -0
- package/template/saas-starter/src/hooks/use-mobile.ts +19 -0
- package/template/saas-starter/src/instrumentation.ts +26 -0
- package/template/saas-starter/src/lib/actions.ts +21 -0
- package/template/saas-starter/src/lib/admin-navigation.ts +31 -0
- package/template/saas-starter/src/lib/app-navigation.ts +59 -0
- package/template/saas-starter/src/lib/auth-client.ts +29 -0
- package/template/saas-starter/src/lib/auth-permissions.ts +22 -0
- package/template/saas-starter/src/lib/auth.ts +211 -0
- package/template/saas-starter/src/lib/billing.ts +117 -0
- package/template/saas-starter/src/lib/cn.ts +4 -0
- package/template/saas-starter/src/lib/constants.ts +22 -0
- package/template/saas-starter/src/lib/delay.ts +2 -0
- package/template/saas-starter/src/lib/email-service.ts +89 -0
- package/template/saas-starter/src/lib/format-currency.ts +27 -0
- package/template/saas-starter/src/lib/format-date.ts +12 -0
- package/template/saas-starter/src/lib/nodemailer.ts +43 -0
- package/template/saas-starter/src/lib/organization-access.ts +93 -0
- package/template/saas-starter/src/lib/organization-plan-limits.ts +38 -0
- package/template/saas-starter/src/lib/otel.ts +27 -0
- package/template/saas-starter/src/lib/plans.ts +63 -0
- package/template/saas-starter/src/lib/safe-action-client.ts +75 -0
- package/template/saas-starter/src/lib/safe-action.ts +67 -0
- package/template/saas-starter/src/lib/siteConfig.ts +4 -0
- package/template/saas-starter/src/lib/storage.ts +181 -0
- package/template/saas-starter/src/lib/stripe.ts +6 -0
- package/template/saas-starter/src/lib/toast-message.ts +15 -0
- package/template/saas-starter/src/lib/validations.ts +12 -0
- package/template/saas-starter/src/scripts/reset-db.ts +22 -0
- package/template/saas-starter/src/scripts/seed.ts +11 -0
- package/template/saas-starter/src/scripts/setup-stripe-plans.ts +200 -0
- package/template/saas-starter/src/scripts/test-mail.ts +35 -0
- 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
|
+
© {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
|
+
})
|