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,415 @@
1
+ 'use client'
2
+
3
+ import { useQueryClient } from '@tanstack/react-query'
4
+ import { ColumnDef } from '@tanstack/react-table'
5
+ import {
6
+ Ban,
7
+ Edit,
8
+ MoreHorizontal,
9
+ Plus,
10
+ Shield,
11
+ Trash2,
12
+ UserCog,
13
+ Users
14
+ } from 'lucide-react'
15
+ import { useRouter } from 'next/navigation'
16
+ import {
17
+ parseAsInteger,
18
+ parseAsJson,
19
+ parseAsString,
20
+ useQueryStates
21
+ } from 'nuqs'
22
+ import { useState } from 'react'
23
+ import { DataTable } from '~/components/data-table'
24
+ import { SortOrderEnum } from '~/components/data-table/enum'
25
+ import { DataTableFilter } from '~/components/data-table/types'
26
+ import { PageHeading } from '~/components/page-heading'
27
+ import { Avatar, AvatarFallback, AvatarImage } from '~/components/ui/avatar'
28
+ import { Badge } from '~/components/ui/badge'
29
+ import { Button } from '~/components/ui/button'
30
+ import {
31
+ DropdownMenu,
32
+ DropdownMenuContent,
33
+ DropdownMenuItem,
34
+ DropdownMenuLabel,
35
+ DropdownMenuSeparator,
36
+ DropdownMenuSub,
37
+ DropdownMenuSubContent,
38
+ DropdownMenuSubTrigger,
39
+ DropdownMenuTrigger
40
+ } from '~/components/ui/dropdown-menu'
41
+ import { admin } from '~/lib/auth-client'
42
+ import { formatDate } from '~/lib/format-date'
43
+ import { useSafeActionQuery } from '~/lib/safe-action-client'
44
+ import { toastErrorMessage, toastSuccessMessage } from '~/lib/toast-message'
45
+ import { getDistinctRoles, getUsers } from '../../../../actions/users'
46
+ import { CreateUserDialog } from './create-user-dialog'
47
+ import { EditNameDialog } from './edit-name-dialog'
48
+
49
+ type User = {
50
+ id: string
51
+ name: string
52
+ email: string
53
+ emailVerified: boolean
54
+ image: string | null
55
+ role: string | null
56
+ banned: boolean | null
57
+ createdAt: Date
58
+ updatedAt: Date
59
+ }
60
+
61
+ const getInitials = (name: string) =>
62
+ name
63
+ .split(' ')
64
+ .map(n => n[0])
65
+ .join('')
66
+ .toUpperCase()
67
+ .slice(0, 2)
68
+
69
+ const ADMIN_ROLES = new Set(['admin', 'superadmin'])
70
+
71
+ function UserActionsCell({
72
+ user,
73
+ isSuperAdmin,
74
+ currentUserId
75
+ }: {
76
+ user: User
77
+ isSuperAdmin: boolean
78
+ currentUserId: string
79
+ }) {
80
+ const isSelf = user.id === currentUserId
81
+ const router = useRouter()
82
+ const queryClient = useQueryClient()
83
+ const [isLoading, setIsLoading] = useState(false)
84
+
85
+ const canImpersonate = isSuperAdmin
86
+ ? user.role !== 'superadmin'
87
+ : !ADMIN_ROLES.has(user.role ?? '')
88
+
89
+ async function handleImpersonate() {
90
+ setIsLoading(true)
91
+ try {
92
+ await admin.impersonateUser({ userId: user.id })
93
+ router.push('/app')
94
+ } catch {
95
+ toastErrorMessage('Failed to impersonate user')
96
+ setIsLoading(false)
97
+ }
98
+ }
99
+
100
+ async function handleToggleBan() {
101
+ const confirmed = window.confirm(
102
+ user.banned
103
+ ? 'Unban this user?'
104
+ : 'Ban this user? They will no longer be able to sign in.'
105
+ )
106
+ if (!confirmed) return
107
+
108
+ setIsLoading(true)
109
+ try {
110
+ const action = user.banned ? admin.unbanUser : admin.banUser
111
+ const { error } = await action({ userId: user.id })
112
+
113
+ if (error) {
114
+ toastErrorMessage(
115
+ error.message ?? (user.banned ? 'Failed to unban' : 'Failed to ban')
116
+ )
117
+ } else {
118
+ toastSuccessMessage(user.banned ? 'User unbanned' : 'User banned')
119
+ queryClient.invalidateQueries({ queryKey: ['admin-users'] })
120
+ }
121
+ } finally {
122
+ setIsLoading(false)
123
+ }
124
+ }
125
+
126
+ async function handleDelete() {
127
+ const confirmed = window.confirm(
128
+ 'Delete this user? This action cannot be undone.'
129
+ )
130
+ if (!confirmed) return
131
+
132
+ setIsLoading(true)
133
+ try {
134
+ const { error } = await admin.removeUser({ userId: user.id })
135
+
136
+ if (error) {
137
+ toastErrorMessage(error.message ?? 'Failed to delete user')
138
+ } else {
139
+ toastSuccessMessage('User deleted')
140
+ queryClient.invalidateQueries({ queryKey: ['admin-users'] })
141
+ }
142
+ } finally {
143
+ setIsLoading(false)
144
+ }
145
+ }
146
+
147
+ async function handleSetRole(role: string) {
148
+ setIsLoading(true)
149
+ try {
150
+ const { error } = await admin.setRole({
151
+ userId: user.id,
152
+ role: role as any
153
+ })
154
+
155
+ if (error) {
156
+ toastErrorMessage(error.message ?? 'Failed to update role')
157
+ } else {
158
+ toastSuccessMessage('Role updated')
159
+ queryClient.invalidateQueries({ queryKey: ['admin-users'] })
160
+ }
161
+ } finally {
162
+ setIsLoading(false)
163
+ }
164
+ }
165
+
166
+ return (
167
+ <DropdownMenu>
168
+ <DropdownMenuTrigger asChild>
169
+ <Button
170
+ variant="ghost"
171
+ size="sm"
172
+ className="h-8 w-8 p-0"
173
+ aria-label="User actions"
174
+ >
175
+ <MoreHorizontal className="h-4 w-4" />
176
+ </Button>
177
+ </DropdownMenuTrigger>
178
+ <DropdownMenuContent align="end" className="w-48">
179
+ <DropdownMenuLabel className="text-muted-foreground max-w-44 truncate text-xs font-normal">
180
+ {user.email}
181
+ </DropdownMenuLabel>
182
+ <DropdownMenuSeparator />
183
+
184
+ <EditNameDialog userId={user.id} currentName={user.name}>
185
+ <DropdownMenuItem onSelect={e => e.preventDefault()}>
186
+ <Edit className="mr-2 h-4 w-4" />
187
+ Edit name
188
+ </DropdownMenuItem>
189
+ </EditNameDialog>
190
+
191
+ {canImpersonate && !isSelf && (
192
+ <DropdownMenuItem disabled={isLoading} onClick={handleImpersonate}>
193
+ <UserCog className="mr-2 h-4 w-4" />
194
+ Impersonate
195
+ </DropdownMenuItem>
196
+ )}
197
+
198
+ {!isSelf && (isSuperAdmin || user.role !== 'superadmin') && (
199
+ <DropdownMenuItem disabled={isLoading} onClick={handleToggleBan}>
200
+ <Ban className="mr-2 h-4 w-4" />
201
+ {user.banned ? 'Unban user' : 'Ban user'}
202
+ </DropdownMenuItem>
203
+ )}
204
+
205
+ {isSuperAdmin && !isSelf && (
206
+ <DropdownMenuSub>
207
+ <DropdownMenuSubTrigger disabled={isLoading}>
208
+ <Shield className="mr-2 h-4 w-4" />
209
+ Set role
210
+ </DropdownMenuSubTrigger>
211
+ <DropdownMenuSubContent>
212
+ {(['user', 'admin', 'superadmin'] as const).map(role => (
213
+ <DropdownMenuItem
214
+ key={role}
215
+ disabled={user.role === role || isLoading}
216
+ onClick={() => handleSetRole(role)}
217
+ className="capitalize"
218
+ >
219
+ {role}
220
+ {user.role === role && (
221
+ <span className="text-muted-foreground ml-auto text-xs">
222
+ current
223
+ </span>
224
+ )}
225
+ </DropdownMenuItem>
226
+ ))}
227
+ </DropdownMenuSubContent>
228
+ </DropdownMenuSub>
229
+ )}
230
+
231
+ {isSuperAdmin && !isSelf && (
232
+ <>
233
+ <DropdownMenuSeparator />
234
+ <DropdownMenuItem
235
+ variant="destructive"
236
+ disabled={isLoading}
237
+ onClick={handleDelete}
238
+ >
239
+ <Trash2 className="mr-2 h-4 w-4" />
240
+ Delete user
241
+ </DropdownMenuItem>
242
+ </>
243
+ )}
244
+ </DropdownMenuContent>
245
+ </DropdownMenu>
246
+ )
247
+ }
248
+
249
+ const filters: DataTableFilter[] = [
250
+ {
251
+ id: 'role',
252
+ label: 'Role',
253
+ action: getDistinctRoles,
254
+ queryKey: 'admin-distinct-roles'
255
+ },
256
+ {
257
+ id: 'banned',
258
+ label: 'Status',
259
+ options: [
260
+ { label: 'Active', value: false },
261
+ { label: 'Banned', value: true }
262
+ ]
263
+ }
264
+ ]
265
+
266
+ interface UsersTableProps {
267
+ isSuperAdmin: boolean
268
+ currentUserId: string
269
+ }
270
+
271
+ export function UsersTable({ isSuperAdmin, currentUserId }: UsersTableProps) {
272
+ const [queryState, setQueryState] = useQueryStates({
273
+ page: parseAsInteger.withDefault(1),
274
+ limit: parseAsInteger.withDefault(10),
275
+ sortBy: parseAsString,
276
+ sortOrder: parseAsString,
277
+ search: parseAsString,
278
+ filters: parseAsJson(v => v as Record<string, (string | boolean)[]>)
279
+ })
280
+
281
+ const params = {
282
+ page: queryState.page,
283
+ limit: queryState.limit,
284
+ sortBy: (queryState.sortBy ?? undefined) as
285
+ | 'name'
286
+ | 'email'
287
+ | 'createdAt'
288
+ | undefined,
289
+ sortOrder: (queryState.sortOrder ?? undefined) as SortOrderEnum | undefined,
290
+ search: queryState.search ?? undefined,
291
+ filters: queryState.filters ?? undefined
292
+ }
293
+
294
+ const { data, isLoading } = useSafeActionQuery(
295
+ 'admin-users',
296
+ getUsers,
297
+ params
298
+ )
299
+
300
+ const columns: ColumnDef<User, unknown>[] = [
301
+ {
302
+ accessorKey: 'name',
303
+ header: 'User',
304
+ enableSorting: true,
305
+ cell: ({ row }) => (
306
+ <div className="flex items-center gap-3">
307
+ <Avatar className="h-8 w-8">
308
+ <AvatarImage src={row.original.image ?? undefined} />
309
+ <AvatarFallback className="text-xs">
310
+ {getInitials(row.original.name)}
311
+ </AvatarFallback>
312
+ </Avatar>
313
+ <div className="flex flex-col">
314
+ <span className="text-sm font-medium">{row.original.name}</span>
315
+ <span className="text-muted-foreground text-xs">
316
+ {row.original.email}
317
+ </span>
318
+ </div>
319
+ </div>
320
+ )
321
+ },
322
+ {
323
+ accessorKey: 'role',
324
+ header: 'Role',
325
+ cell: ({ row }) => (
326
+ <Badge variant="outline" className="capitalize">
327
+ {row.original.role ?? 'user'}
328
+ </Badge>
329
+ )
330
+ },
331
+ {
332
+ accessorKey: 'banned',
333
+ header: 'Status',
334
+ enableSorting: false,
335
+ cell: ({ row }) => (
336
+ <Badge variant={row.original.banned ? 'destructive' : 'default'}>
337
+ {row.original.banned ? 'Banned' : 'Active'}
338
+ </Badge>
339
+ )
340
+ },
341
+ {
342
+ accessorKey: 'emailVerified',
343
+ header: 'Verified',
344
+ enableSorting: false,
345
+ cell: ({ row }) => (
346
+ <Badge variant={row.original.emailVerified ? 'default' : 'secondary'}>
347
+ {row.original.emailVerified ? 'Verified' : 'Unverified'}
348
+ </Badge>
349
+ )
350
+ },
351
+ {
352
+ accessorKey: 'createdAt',
353
+ header: 'Joined',
354
+ enableSorting: true,
355
+ cell: ({ row }) => formatDate(row.original.createdAt, { short: true })
356
+ },
357
+ {
358
+ id: 'actions',
359
+ header: '',
360
+ enableSorting: false,
361
+ cell: ({ row }) => (
362
+ <UserActionsCell
363
+ user={row.original}
364
+ isSuperAdmin={isSuperAdmin}
365
+ currentUserId={currentUserId}
366
+ />
367
+ )
368
+ }
369
+ ]
370
+
371
+ return (
372
+ <div className="flex flex-1 flex-col gap-6 p-4">
373
+ <PageHeading
374
+ title="Users"
375
+ description="Manage users and their roles."
376
+ icon={Users}
377
+ >
378
+ {isSuperAdmin && (
379
+ <CreateUserDialog>
380
+ <Button size="lg">
381
+ <Plus className="mr-2 h-4 w-4" />
382
+ New user
383
+ </Button>
384
+ </CreateUserDialog>
385
+ )}
386
+ </PageHeading>
387
+
388
+ <DataTable
389
+ columns={columns}
390
+ data={data?.[0]}
391
+ isLoading={isLoading}
392
+ totalCount={data?.[1]}
393
+ enableSearch
394
+ initialSearch={params.search}
395
+ searchPlaceholder="Search users..."
396
+ manualPagination
397
+ manualSorting
398
+ pageIndex={params.page - 1}
399
+ pageSize={params.limit}
400
+ filters={filters}
401
+ activeFilters={params.filters ?? {}}
402
+ onParamsChange={({ page, limit, sortBy, sortOrder, search, filters }) =>
403
+ setQueryState({
404
+ page,
405
+ limit,
406
+ sortBy: sortBy ?? null,
407
+ sortOrder: sortOrder ?? null,
408
+ search: search ?? null,
409
+ filters: filters ?? null
410
+ })
411
+ }
412
+ />
413
+ </div>
414
+ )
415
+ }
@@ -0,0 +1,15 @@
1
+ import { notFound } from 'next/navigation'
2
+ import { getAuthSession } from '~/lib/auth'
3
+ import { UsersTable } from './_components/users-table'
4
+
5
+ export default async function UsersPage() {
6
+ const authSession = await getAuthSession()
7
+ if (!authSession) return notFound()
8
+
9
+ return (
10
+ <UsersTable
11
+ isSuperAdmin={authSession.isSuperAdmin ?? false}
12
+ currentUserId={authSession.user.id}
13
+ />
14
+ )
15
+ }
@@ -0,0 +1,152 @@
1
+ 'use server'
2
+
3
+ import { eq } from 'drizzle-orm'
4
+ import { z } from 'zod'
5
+ import { db } from '~/db'
6
+ import { organizationSubscriptionsTable } from '~/db/schema'
7
+ import { env } from '~/env'
8
+ import { canStartTrial, ensureStripeCustomer } from '~/lib/billing'
9
+ import { TRIAL_DAYS } from '~/lib/constants'
10
+ import { assertUserCanManageOrganization } from '~/lib/organization-access'
11
+ import { PLANS } from '~/lib/plans'
12
+ import { authActionClient } from '~/lib/safe-action'
13
+ import { stripeClient } from '~/lib/stripe'
14
+ import { emailValidation, stringValidation } from '~/lib/validations'
15
+
16
+ const ACTIVE_STATUSES = ['trialing', 'active', 'past_due'] as const
17
+
18
+ const billingReturnUrl = (organizationId: string) =>
19
+ `${env.BETTER_AUTH_URL}/app/${organizationId}/settings/billing`
20
+
21
+ export const updateBillingEmailAction = authActionClient
22
+ .inputSchema(
23
+ z.object({
24
+ organizationId: stringValidation,
25
+ billingEmail: emailValidation
26
+ })
27
+ )
28
+ .action(async ({ parsedInput: { organizationId, billingEmail } }) => {
29
+ await assertUserCanManageOrganization(organizationId)
30
+
31
+ const sub = await db.query.organizationSubscriptionsTable.findFirst({
32
+ where: eq(organizationSubscriptionsTable.organizationId, organizationId)
33
+ })
34
+
35
+ if (!sub) {
36
+ throw new Error('Organization does not have a subscription')
37
+ }
38
+
39
+ if (sub.stripeCustomerId) {
40
+ await stripeClient.customers.update(sub.stripeCustomerId, {
41
+ email: billingEmail
42
+ })
43
+ }
44
+
45
+ await db
46
+ .update(organizationSubscriptionsTable)
47
+ .set({ billingEmail })
48
+ .where(eq(organizationSubscriptionsTable.organizationId, organizationId))
49
+
50
+ return true
51
+ })
52
+
53
+ export const createCheckoutSessionAction = authActionClient
54
+ .inputSchema(
55
+ z.object({
56
+ organizationId: stringValidation,
57
+ plan: z.enum(['pro', 'ultimate']),
58
+ interval: z.enum(['monthly', 'annual']),
59
+ withTrial: z.boolean().default(false)
60
+ })
61
+ )
62
+ .action(
63
+ async ({ parsedInput: { organizationId, plan, interval, withTrial } }) => {
64
+ await assertUserCanManageOrganization(organizationId)
65
+
66
+ const sub = await db.query.organizationSubscriptionsTable.findFirst({
67
+ where: eq(organizationSubscriptionsTable.organizationId, organizationId)
68
+ })
69
+
70
+ if (!sub) {
71
+ throw new Error('Organization does not have a subscription')
72
+ }
73
+
74
+ if ((ACTIVE_STATUSES as readonly string[]).includes(sub.status)) {
75
+ throw new Error(
76
+ 'This organization already has a subscription. Manage it from the billing portal.'
77
+ )
78
+ }
79
+
80
+ const planDef = PLANS.find(p => p.id === plan)
81
+ const price = planDef?.stripePrice?.[interval]
82
+ if (!price) {
83
+ throw new Error('Selected plan is not available')
84
+ }
85
+
86
+ const trialAllowed =
87
+ withTrial && Boolean(planDef?.canUseTrial) && canStartTrial(sub)
88
+
89
+ const customerId = await ensureStripeCustomer(organizationId)
90
+
91
+ if (trialAllowed) {
92
+ await db
93
+ .update(organizationSubscriptionsTable)
94
+ .set({ hasUsedTrial: true })
95
+ .where(
96
+ eq(organizationSubscriptionsTable.organizationId, organizationId)
97
+ )
98
+ }
99
+
100
+ const session = await stripeClient.checkout.sessions.create({
101
+ mode: 'subscription',
102
+ customer: customerId,
103
+ line_items: [{ price: price.id, quantity: 1 }],
104
+ client_reference_id: organizationId,
105
+ allow_promotion_codes: true,
106
+ success_url: `${billingReturnUrl(organizationId)}?checkout=success`,
107
+ cancel_url: `${billingReturnUrl(organizationId)}?checkout=cancel`,
108
+ subscription_data: {
109
+ metadata: { organizationId },
110
+ ...(trialAllowed
111
+ ? {
112
+ trial_period_days: TRIAL_DAYS,
113
+ trial_settings: {
114
+ end_behavior: { missing_payment_method: 'cancel' }
115
+ }
116
+ }
117
+ : {})
118
+ },
119
+ ...(trialAllowed
120
+ ? { payment_method_collection: 'always' as const }
121
+ : {})
122
+ })
123
+
124
+ if (!session.url) {
125
+ throw new Error('Failed to create checkout session')
126
+ }
127
+
128
+ return { url: session.url }
129
+ }
130
+ )
131
+
132
+ export const createPortalSessionAction = authActionClient
133
+ .inputSchema(z.object({ organizationId: stringValidation }))
134
+ .action(async ({ parsedInput: { organizationId } }) => {
135
+ await assertUserCanManageOrganization(organizationId)
136
+
137
+ const sub = await db.query.organizationSubscriptionsTable.findFirst({
138
+ where: eq(organizationSubscriptionsTable.organizationId, organizationId)
139
+ })
140
+
141
+ if (!sub?.stripeCustomerId) {
142
+ throw new Error('No billing account exists for this organization yet')
143
+ }
144
+
145
+ const session = await stripeClient.billingPortal.sessions.create({
146
+ customer: sub.stripeCustomerId,
147
+ return_url: billingReturnUrl(organizationId),
148
+ configuration: env.STRIPE_PORTAL_CONFIGURATION_ID
149
+ })
150
+
151
+ return { url: session.url }
152
+ })