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,571 @@
1
+ 'use client'
2
+
3
+ import { useQueryClient } from '@tanstack/react-query'
4
+ import { ColumnDef } from '@tanstack/react-table'
5
+ import {
6
+ MoreHorizontal,
7
+ RefreshCcw,
8
+ Trash2,
9
+ UserPlus,
10
+ XCircle
11
+ } from 'lucide-react'
12
+ import { useRouter } from 'next/navigation'
13
+ import {
14
+ parseAsInteger,
15
+ parseAsJson,
16
+ parseAsString,
17
+ useQueryStates
18
+ } from 'nuqs'
19
+ import { useState } from 'react'
20
+ import { DataTable } from '~/components/data-table'
21
+ import { SortOrderEnum } from '~/components/data-table/enum'
22
+ import { DataTableFilter } from '~/components/data-table/types'
23
+ import { Avatar, AvatarFallback, AvatarImage } from '~/components/ui/avatar'
24
+ import { Badge } from '~/components/ui/badge'
25
+ import { Button } from '~/components/ui/button'
26
+ import {
27
+ DropdownMenu,
28
+ DropdownMenuContent,
29
+ DropdownMenuItem,
30
+ DropdownMenuSeparator,
31
+ DropdownMenuTrigger
32
+ } from '~/components/ui/dropdown-menu'
33
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '~/components/ui/tabs'
34
+ import {
35
+ Tooltip,
36
+ TooltipContent,
37
+ TooltipTrigger
38
+ } from '~/components/ui/tooltip'
39
+ import { organization } from '~/lib/auth-client'
40
+ import { formatDate } from '~/lib/format-date'
41
+ import { useSafeActionQuery } from '~/lib/safe-action-client'
42
+ import { toastErrorMessage, toastSuccessMessage } from '~/lib/toast-message'
43
+ import { getInvitations, getMembers } from '../../../../../actions/members'
44
+ import { useOrganizationContext } from '../../../../_components/organization-context'
45
+ import { InviteMemberDialog } from './invite-member-dialog'
46
+
47
+ type Member = {
48
+ id: string
49
+ userId: string
50
+ name: string
51
+ email: string
52
+ image: string | null
53
+ role: string
54
+ createdAt: Date
55
+ }
56
+
57
+ type Invitation = {
58
+ id: string
59
+ email: string
60
+ role: string
61
+ status: string
62
+ expiresAt: Date | null
63
+ createdAt: Date
64
+ inviterName: string | null
65
+ }
66
+
67
+ const MEMBERS_QUERY_KEY = 'org-members'
68
+ const INVITATIONS_QUERY_KEY = 'org-invitations'
69
+
70
+ function roleVariant(role: string): 'default' | 'secondary' | 'outline' {
71
+ if (role === 'owner') return 'default'
72
+ if (role === 'admin') return 'secondary'
73
+ return 'outline'
74
+ }
75
+
76
+ function statusVariant(
77
+ status: string
78
+ ): 'default' | 'secondary' | 'outline' | 'destructive' {
79
+ if (status === 'accepted') return 'default'
80
+ if (status === 'pending') return 'secondary'
81
+ return 'destructive'
82
+ }
83
+
84
+ function getInitials(name: string, email: string) {
85
+ const source = (name || email || '').trim()
86
+ if (!source) return '?'
87
+ const parts = source.split(/\s+/)
88
+ if (parts.length === 1) return parts[0]!.slice(0, 2).toUpperCase()
89
+ return (parts[0]![0]! + parts[1]![0]!).toUpperCase()
90
+ }
91
+
92
+ const memberFilters: DataTableFilter[] = [
93
+ {
94
+ id: 'role',
95
+ label: 'Role',
96
+ options: [
97
+ { label: 'Owner', value: 'owner' },
98
+ { label: 'Admin', value: 'admin' },
99
+ { label: 'Member', value: 'member' }
100
+ ]
101
+ }
102
+ ]
103
+
104
+ const invitationFilters: DataTableFilter[] = [
105
+ {
106
+ id: 'status',
107
+ label: 'Status',
108
+ options: [
109
+ { label: 'Pending', value: 'pending' },
110
+ { label: 'Accepted', value: 'accepted' },
111
+ { label: 'Rejected', value: 'rejected' },
112
+ { label: 'Canceled', value: 'canceled' }
113
+ ]
114
+ }
115
+ ]
116
+
117
+ export function MembersTabs({
118
+ memberCount,
119
+ maxMembers
120
+ }: {
121
+ memberCount: number
122
+ maxMembers: number
123
+ }) {
124
+ const {
125
+ id: orgId,
126
+ role: currentRole,
127
+ memberId: currentMemberId
128
+ } = useOrganizationContext()
129
+ const router = useRouter()
130
+ const queryClient = useQueryClient()
131
+ const [busyId, setBusyId] = useState<string | null>(null)
132
+
133
+ const atMemberLimit = memberCount >= maxMembers
134
+
135
+ const invalidateMembers = () =>
136
+ queryClient.invalidateQueries({ queryKey: [MEMBERS_QUERY_KEY] })
137
+ const invalidateInvitations = () =>
138
+ queryClient.invalidateQueries({ queryKey: [INVITATIONS_QUERY_KEY] })
139
+
140
+ const [memberQuery, setMemberQuery] = useQueryStates(
141
+ {
142
+ mPage: parseAsInteger.withDefault(1),
143
+ mLimit: parseAsInteger.withDefault(10),
144
+ mSortBy: parseAsString,
145
+ mSortOrder: parseAsString,
146
+ mSearch: parseAsString,
147
+ mFilters: parseAsJson(v => v as Record<string, (string | boolean)[]>)
148
+ },
149
+ { history: 'replace' }
150
+ )
151
+
152
+ const memberParams = {
153
+ organizationId: orgId,
154
+ page: memberQuery.mPage,
155
+ limit: memberQuery.mLimit,
156
+ sortBy: (memberQuery.mSortBy ?? undefined) as
157
+ | 'name'
158
+ | 'email'
159
+ | 'role'
160
+ | 'createdAt'
161
+ | undefined,
162
+ sortOrder: (memberQuery.mSortOrder ?? undefined) as
163
+ | SortOrderEnum
164
+ | undefined,
165
+ search: memberQuery.mSearch ?? undefined,
166
+ filters: memberQuery.mFilters ?? undefined
167
+ }
168
+
169
+ const { data: memberData, isLoading: membersLoading } = useSafeActionQuery(
170
+ MEMBERS_QUERY_KEY,
171
+ getMembers,
172
+ memberParams
173
+ )
174
+
175
+ async function changeRole(member: Member, role: 'admin' | 'member') {
176
+ setBusyId(member.id)
177
+ const { error } = await organization.updateMemberRole({
178
+ organizationId: orgId,
179
+ memberId: member.id,
180
+ role
181
+ })
182
+ setBusyId(null)
183
+ if (error) {
184
+ toastErrorMessage(error.message ?? 'Failed to update role')
185
+ return
186
+ }
187
+ toastSuccessMessage(`${member.name} is now ${role}`)
188
+ invalidateMembers()
189
+ }
190
+
191
+ async function removeMember(member: Member) {
192
+ if (!confirm(`Remove ${member.name} from this organization?`)) return
193
+ setBusyId(member.id)
194
+ const { error } = await organization.removeMember({
195
+ organizationId: orgId,
196
+ memberIdOrEmail: member.id
197
+ })
198
+ setBusyId(null)
199
+ if (error) {
200
+ toastErrorMessage(error.message ?? 'Failed to remove member')
201
+ return
202
+ }
203
+ toastSuccessMessage(`${member.name} removed`)
204
+ invalidateMembers()
205
+ // Refresh the server component so the member-limit gate reflects the freed slot.
206
+ router.refresh()
207
+ }
208
+
209
+ const memberColumns: ColumnDef<Member, unknown>[] = [
210
+ {
211
+ accessorKey: 'name',
212
+ header: 'Member',
213
+ enableSorting: true,
214
+ cell: ({ row }) => {
215
+ const member = row.original
216
+ const isSelf = member.id === currentMemberId
217
+ return (
218
+ <div className="flex items-center gap-3">
219
+ <Avatar className="size-8">
220
+ {member.image ? (
221
+ <AvatarImage src={member.image} alt={member.name} />
222
+ ) : null}
223
+ <AvatarFallback className="text-xs">
224
+ {getInitials(member.name, member.email)}
225
+ </AvatarFallback>
226
+ </Avatar>
227
+ <div className="flex flex-col">
228
+ <span className="text-sm font-medium">
229
+ {member.name}
230
+ {isSelf ? (
231
+ <span className="text-muted-foreground"> (you)</span>
232
+ ) : null}
233
+ </span>
234
+ <span className="text-muted-foreground text-xs">
235
+ {member.email}
236
+ </span>
237
+ </div>
238
+ </div>
239
+ )
240
+ }
241
+ },
242
+ {
243
+ accessorKey: 'role',
244
+ header: 'Role',
245
+ enableSorting: true,
246
+ cell: ({ row }) => (
247
+ <Badge variant={roleVariant(row.original.role)} className="capitalize">
248
+ {row.original.role}
249
+ </Badge>
250
+ )
251
+ },
252
+ {
253
+ accessorKey: 'createdAt',
254
+ header: 'Joined',
255
+ enableSorting: true,
256
+ cell: ({ row }) => formatDate(row.original.createdAt, { short: true })
257
+ },
258
+ {
259
+ id: 'actions',
260
+ header: '',
261
+ enableSorting: false,
262
+ cell: ({ row }) => {
263
+ const member = row.original
264
+ // Owners manage everyone; admins manage only members. Never self/owner.
265
+ const canAct =
266
+ member.role !== 'owner' &&
267
+ member.id !== currentMemberId &&
268
+ (currentRole === 'owner' || member.role === 'member')
269
+
270
+ if (!canAct) return null
271
+
272
+ return (
273
+ <DropdownMenu>
274
+ <DropdownMenuTrigger asChild>
275
+ <Button
276
+ variant="ghost"
277
+ size="sm"
278
+ className="h-8 w-8 p-0"
279
+ disabled={busyId === member.id}
280
+ aria-label="Member actions"
281
+ >
282
+ <MoreHorizontal className="h-4 w-4" />
283
+ </Button>
284
+ </DropdownMenuTrigger>
285
+ <DropdownMenuContent align="end">
286
+ {member.role !== 'admin' ? (
287
+ <DropdownMenuItem onClick={() => changeRole(member, 'admin')}>
288
+ Make admin
289
+ </DropdownMenuItem>
290
+ ) : null}
291
+ {member.role !== 'member' ? (
292
+ <DropdownMenuItem onClick={() => changeRole(member, 'member')}>
293
+ Make member
294
+ </DropdownMenuItem>
295
+ ) : null}
296
+ <DropdownMenuSeparator />
297
+ <DropdownMenuItem
298
+ variant="destructive"
299
+ onClick={() => removeMember(member)}
300
+ >
301
+ <Trash2 className="mr-2 h-4 w-4" />
302
+ Remove
303
+ </DropdownMenuItem>
304
+ </DropdownMenuContent>
305
+ </DropdownMenu>
306
+ )
307
+ }
308
+ }
309
+ ]
310
+
311
+ const [inviteQuery, setInviteQuery] = useQueryStates(
312
+ {
313
+ iPage: parseAsInteger.withDefault(1),
314
+ iLimit: parseAsInteger.withDefault(10),
315
+ iSortBy: parseAsString,
316
+ iSortOrder: parseAsString,
317
+ iSearch: parseAsString,
318
+ iFilters: parseAsJson(v => v as Record<string, (string | boolean)[]>)
319
+ },
320
+ { history: 'replace' }
321
+ )
322
+
323
+ const inviteParams = {
324
+ organizationId: orgId,
325
+ page: inviteQuery.iPage,
326
+ limit: inviteQuery.iLimit,
327
+ sortBy: (inviteQuery.iSortBy ?? undefined) as
328
+ | 'email'
329
+ | 'status'
330
+ | 'createdAt'
331
+ | 'expiresAt'
332
+ | undefined,
333
+ sortOrder: (inviteQuery.iSortOrder ?? undefined) as
334
+ | SortOrderEnum
335
+ | undefined,
336
+ search: inviteQuery.iSearch ?? undefined,
337
+ filters: inviteQuery.iFilters ?? undefined
338
+ }
339
+
340
+ const { data: inviteData, isLoading: invitesLoading } = useSafeActionQuery(
341
+ INVITATIONS_QUERY_KEY,
342
+ getInvitations,
343
+ inviteParams
344
+ )
345
+
346
+ async function resendInvitation(invitation: Invitation) {
347
+ setBusyId(invitation.id)
348
+ const { error } = await organization.inviteMember({
349
+ organizationId: orgId,
350
+ email: invitation.email,
351
+ role: invitation.role === 'admin' ? 'admin' : 'member',
352
+ resend: true
353
+ })
354
+ setBusyId(null)
355
+ if (error) {
356
+ toastErrorMessage(error.message ?? 'Failed to resend invitation')
357
+ return
358
+ }
359
+ toastSuccessMessage(`Invitation resent to ${invitation.email}`)
360
+ invalidateInvitations()
361
+ }
362
+
363
+ async function cancelInvitation(invitation: Invitation) {
364
+ if (!confirm(`Cancel the invitation for ${invitation.email}?`)) return
365
+ setBusyId(invitation.id)
366
+ const { error } = await organization.cancelInvitation({
367
+ invitationId: invitation.id
368
+ })
369
+ setBusyId(null)
370
+ if (error) {
371
+ toastErrorMessage(error.message ?? 'Failed to cancel invitation')
372
+ return
373
+ }
374
+ toastSuccessMessage('Invitation cancelled')
375
+ invalidateInvitations()
376
+ }
377
+
378
+ const invitationColumns: ColumnDef<Invitation, unknown>[] = [
379
+ {
380
+ accessorKey: 'email',
381
+ header: 'Email',
382
+ enableSorting: true,
383
+ cell: ({ row }) => (
384
+ <div className="flex flex-col">
385
+ <span className="text-sm font-medium">{row.original.email}</span>
386
+ {row.original.inviterName ? (
387
+ <span className="text-muted-foreground text-xs">
388
+ Invited by {row.original.inviterName}
389
+ </span>
390
+ ) : null}
391
+ </div>
392
+ )
393
+ },
394
+ {
395
+ accessorKey: 'role',
396
+ header: 'Role',
397
+ enableSorting: false,
398
+ cell: ({ row }) => (
399
+ <Badge variant={roleVariant(row.original.role)} className="capitalize">
400
+ {row.original.role}
401
+ </Badge>
402
+ )
403
+ },
404
+ {
405
+ accessorKey: 'status',
406
+ header: 'Status',
407
+ enableSorting: true,
408
+ cell: ({ row }) => {
409
+ const expired =
410
+ row.original.status === 'pending' &&
411
+ row.original.expiresAt != null &&
412
+ new Date(row.original.expiresAt).getTime() < Date.now()
413
+ const status = expired ? 'expired' : row.original.status
414
+ return (
415
+ <Badge variant={statusVariant(status)} className="capitalize">
416
+ {status}
417
+ </Badge>
418
+ )
419
+ }
420
+ },
421
+ {
422
+ accessorKey: 'expiresAt',
423
+ header: 'Expires',
424
+ enableSorting: true,
425
+ cell: ({ row }) => formatDate(row.original.expiresAt, { short: true })
426
+ },
427
+ {
428
+ id: 'actions',
429
+ header: '',
430
+ enableSorting: false,
431
+ cell: ({ row }) => {
432
+ const invitation = row.original
433
+ if (invitation.status !== 'pending') return null
434
+ return (
435
+ <DropdownMenu>
436
+ <DropdownMenuTrigger asChild>
437
+ <Button
438
+ variant="ghost"
439
+ size="sm"
440
+ className="h-8 w-8 p-0"
441
+ disabled={busyId === invitation.id}
442
+ aria-label="Invitation actions"
443
+ >
444
+ <MoreHorizontal className="h-4 w-4" />
445
+ </Button>
446
+ </DropdownMenuTrigger>
447
+ <DropdownMenuContent align="end">
448
+ <DropdownMenuItem onClick={() => resendInvitation(invitation)}>
449
+ <RefreshCcw className="mr-2 h-4 w-4" />
450
+ Resend
451
+ </DropdownMenuItem>
452
+ <DropdownMenuSeparator />
453
+ <DropdownMenuItem
454
+ variant="destructive"
455
+ onClick={() => cancelInvitation(invitation)}
456
+ >
457
+ <XCircle className="mr-2 h-4 w-4" />
458
+ Cancel
459
+ </DropdownMenuItem>
460
+ </DropdownMenuContent>
461
+ </DropdownMenu>
462
+ )
463
+ }
464
+ }
465
+ ]
466
+
467
+ return (
468
+ <Tabs defaultValue="members" className="w-full">
469
+ <div className="flex items-center justify-between gap-4">
470
+ <TabsList>
471
+ <TabsTrigger value="members">Members</TabsTrigger>
472
+ <TabsTrigger value="invitations">Invitations</TabsTrigger>
473
+ </TabsList>
474
+
475
+ {atMemberLimit ? (
476
+ <Tooltip>
477
+ <TooltipTrigger asChild>
478
+ <span tabIndex={0}>
479
+ <Button size="sm" disabled>
480
+ <UserPlus className="mr-2 size-4" />
481
+ Invite member
482
+ </Button>
483
+ </span>
484
+ </TooltipTrigger>
485
+ <TooltipContent>
486
+ Member limit reached ({memberCount}/{maxMembers}). Upgrade your
487
+ plan to invite more members.
488
+ </TooltipContent>
489
+ </Tooltip>
490
+ ) : (
491
+ <InviteMemberDialog onInvited={invalidateInvitations}>
492
+ <Button size="sm">
493
+ <UserPlus className="mr-2 size-4" />
494
+ Invite member
495
+ </Button>
496
+ </InviteMemberDialog>
497
+ )}
498
+ </div>
499
+
500
+ <TabsContent value="members">
501
+ <DataTable
502
+ columns={memberColumns}
503
+ data={memberData?.[0]}
504
+ isLoading={membersLoading}
505
+ totalCount={memberData?.[1]}
506
+ enableSearch
507
+ initialSearch={memberParams.search}
508
+ searchPlaceholder="Search members..."
509
+ manualPagination
510
+ manualSorting
511
+ pageIndex={memberParams.page - 1}
512
+ pageSize={memberParams.limit}
513
+ filters={memberFilters}
514
+ activeFilters={memberParams.filters ?? {}}
515
+ onParamsChange={({
516
+ page,
517
+ limit,
518
+ sortBy,
519
+ sortOrder,
520
+ search,
521
+ filters
522
+ }) =>
523
+ setMemberQuery({
524
+ mPage: page,
525
+ mLimit: limit,
526
+ mSortBy: sortBy ?? null,
527
+ mSortOrder: sortOrder ?? null,
528
+ mSearch: search ?? null,
529
+ mFilters: filters ?? null
530
+ })
531
+ }
532
+ />
533
+ </TabsContent>
534
+
535
+ <TabsContent value="invitations">
536
+ <DataTable
537
+ columns={invitationColumns}
538
+ data={inviteData?.[0]}
539
+ isLoading={invitesLoading}
540
+ totalCount={inviteData?.[1]}
541
+ enableSearch
542
+ initialSearch={inviteParams.search}
543
+ searchPlaceholder="Search invitations..."
544
+ manualPagination
545
+ manualSorting
546
+ pageIndex={inviteParams.page - 1}
547
+ pageSize={inviteParams.limit}
548
+ filters={invitationFilters}
549
+ activeFilters={inviteParams.filters ?? {}}
550
+ onParamsChange={({
551
+ page,
552
+ limit,
553
+ sortBy,
554
+ sortOrder,
555
+ search,
556
+ filters
557
+ }) =>
558
+ setInviteQuery({
559
+ iPage: page,
560
+ iLimit: limit,
561
+ iSortBy: sortBy ?? null,
562
+ iSortOrder: sortOrder ?? null,
563
+ iSearch: search ?? null,
564
+ iFilters: filters ?? null
565
+ })
566
+ }
567
+ />
568
+ </TabsContent>
569
+ </Tabs>
570
+ )
571
+ }
@@ -0,0 +1,40 @@
1
+ import { Users } from 'lucide-react'
2
+ import { redirect } from 'next/navigation'
3
+ import { PageHeading } from '~/components/page-heading'
4
+ import { canManageOrganization } from '~/lib/app-navigation'
5
+ import {
6
+ getOrganizationMemberCount,
7
+ getUserMembershipCached
8
+ } from '~/lib/organization-access'
9
+ import { getOrganizationPlanCached } from '~/lib/organization-plan-limits'
10
+ import { MembersTabs } from './_components/members-tabs'
11
+
12
+ export default async function Page({
13
+ params
14
+ }: {
15
+ params: Promise<{ orgId: string }>
16
+ }) {
17
+ const { orgId } = await params
18
+
19
+ const membership = await getUserMembershipCached(orgId)
20
+ if (!membership || !canManageOrganization(membership.role)) {
21
+ return redirect(`/app/${orgId}/dashboard`)
22
+ }
23
+
24
+ const [memberCount, { plan }] = await Promise.all([
25
+ getOrganizationMemberCount(orgId),
26
+ getOrganizationPlanCached(orgId)
27
+ ])
28
+
29
+ return (
30
+ <div className="mx-auto w-full max-w-4xl space-y-6">
31
+ <PageHeading
32
+ title="Members"
33
+ description="Invite teammates, manage roles and review pending invitations."
34
+ icon={Users}
35
+ />
36
+
37
+ <MembersTabs memberCount={memberCount} maxMembers={plan.maxMembers} />
38
+ </div>
39
+ )
40
+ }
@@ -0,0 +1,69 @@
1
+ import { Settings } from 'lucide-react'
2
+ import { redirect } from 'next/navigation'
3
+ import { PageHeading } from '~/components/page-heading'
4
+ import { Separator } from '~/components/ui/separator'
5
+ import { canManageOrganization } from '~/lib/app-navigation'
6
+ import {
7
+ getOrganizationMembers,
8
+ getUserMembershipCached,
9
+ getUserOrganizationsCached
10
+ } from '~/lib/organization-access'
11
+ import { resolveImageUrl } from '~/lib/storage'
12
+ import { DangerZone } from './_components/danger-zone'
13
+ import { GeneralSettingsForm } from './_components/general-settings-form'
14
+
15
+ export default async function Page({
16
+ params
17
+ }: {
18
+ params: Promise<{ orgId: string }>
19
+ }) {
20
+ const { orgId } = await params
21
+
22
+ const membership = await getUserMembershipCached(orgId)
23
+ if (!membership || !canManageOrganization(membership.role)) {
24
+ return redirect(`/app/${orgId}/dashboard`)
25
+ }
26
+
27
+ const org = (await getUserOrganizationsCached()).find(o => o.id === orgId)
28
+ if (!org) {
29
+ return redirect('/app')
30
+ }
31
+
32
+ const members = await getOrganizationMembers(orgId)
33
+ const isOwner = membership.role === 'owner'
34
+ const transferTargets = members
35
+ .filter(m => m.id !== membership.id)
36
+ .map(m => ({
37
+ memberId: m.id,
38
+ name: m.user.name,
39
+ email: m.user.email,
40
+ role: m.role
41
+ }))
42
+
43
+ return (
44
+ <div className="mx-auto w-full max-w-3xl space-y-8">
45
+ <PageHeading
46
+ title="General"
47
+ description="Manage your organization's profile and settings."
48
+ icon={Settings}
49
+ />
50
+
51
+ <GeneralSettingsForm
52
+ orgId={orgId}
53
+ defaultName={org.name}
54
+ defaultSlug={org.slug}
55
+ currentLogoUrl={resolveImageUrl(org.logo)}
56
+ currentLogoKey={org.logo}
57
+ />
58
+
59
+ <Separator />
60
+
61
+ <DangerZone
62
+ orgId={orgId}
63
+ orgName={org.name}
64
+ isOwner={isOwner}
65
+ transferTargets={transferTargets}
66
+ />
67
+ </div>
68
+ )
69
+ }
@@ -0,0 +1,29 @@
1
+ 'use client'
2
+
3
+ import { useParams } from 'next/navigation'
4
+ import { FC } from 'react'
5
+ import { DashboardHeader } from '~/components/dashboard-header'
6
+ import type { UserOrganization } from '~/lib/app-navigation'
7
+ import { AppSearch } from './app-search'
8
+
9
+ export const AppHeader: FC<{
10
+ user: {
11
+ name?: string | null
12
+ email?: string | null
13
+ image?: string | null
14
+ }
15
+ organizations: UserOrganization[]
16
+ }> = ({ user, organizations }) => {
17
+ const params = useParams()
18
+ const orgId = params?.orgId as string | undefined
19
+ const role = organizations.find(o => o.id === orgId)?.role
20
+
21
+ return (
22
+ <DashboardHeader
23
+ user={user}
24
+ search={
25
+ orgId && role ? <AppSearch orgId={orgId} role={role} /> : undefined
26
+ }
27
+ />
28
+ )
29
+ }