create-ej-app 24.0.0 → 26.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/cli.js +6 -0
  2. package/cli.ts +8 -0
  3. package/package.json +2 -2
  4. package/template/api/package.json +2 -2
  5. package/template/nextjs/gitignore +1 -0
  6. package/template/nextjs/package.json +20 -20
  7. package/template/nextjs/postcss.config.mjs +3 -1
  8. package/template/saas-starter/.dockerignore +7 -0
  9. package/template/saas-starter/.env.example +25 -0
  10. package/template/saas-starter/.vscode/extensions.json +11 -0
  11. package/template/saas-starter/.vscode/settings.json +3 -0
  12. package/template/saas-starter/Dockerfile +15 -0
  13. package/template/saas-starter/README.md +3 -0
  14. package/template/saas-starter/components.json +25 -0
  15. package/template/saas-starter/drizzle.config.ts +10 -0
  16. package/template/saas-starter/eslint.config.mjs +23 -0
  17. package/template/saas-starter/gitignore +9 -0
  18. package/template/saas-starter/next-env.d.ts +6 -0
  19. package/template/saas-starter/next.config.ts +14 -0
  20. package/template/saas-starter/package.json +92 -0
  21. package/template/saas-starter/postcss.config.mjs +7 -0
  22. package/template/saas-starter/prettier.config.mjs +11 -0
  23. package/template/saas-starter/public/placeholder.svg +1 -0
  24. package/template/saas-starter/src/actions/uploads.ts +28 -0
  25. package/template/saas-starter/src/actions/uploads.validation.ts +14 -0
  26. package/template/saas-starter/src/app/(admin)/actions/users.ts +97 -0
  27. package/template/saas-starter/src/app/(admin)/admin/_components/admin-header.tsx +21 -0
  28. package/template/saas-starter/src/app/(admin)/admin/_components/admin-search.tsx +16 -0
  29. package/template/saas-starter/src/app/(admin)/admin/_components/admin-sidebar.tsx +40 -0
  30. package/template/saas-starter/src/app/(admin)/admin/layout.tsx +57 -0
  31. package/template/saas-starter/src/app/(admin)/admin/page.tsx +3 -0
  32. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/create-user-dialog.tsx +183 -0
  33. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/edit-name-dialog.tsx +129 -0
  34. package/template/saas-starter/src/app/(admin)/admin/settings/users/_components/users-table.tsx +415 -0
  35. package/template/saas-starter/src/app/(admin)/admin/settings/users/page.tsx +15 -0
  36. package/template/saas-starter/src/app/(app)/actions/billing.ts +152 -0
  37. package/template/saas-starter/src/app/(app)/actions/members.ts +163 -0
  38. package/template/saas-starter/src/app/(app)/actions/organization.ts +92 -0
  39. package/template/saas-starter/src/app/(app)/app/[orgId]/dashboard/page.tsx +20 -0
  40. package/template/saas-starter/src/app/(app)/app/[orgId]/layout.tsx +34 -0
  41. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/billing-email-form.tsx +100 -0
  42. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/danger-zone.tsx +364 -0
  43. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/general-settings-form.tsx +155 -0
  44. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/_components/plans-section.tsx +232 -0
  45. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/billing/page.tsx +116 -0
  46. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/invite-member-dialog.tsx +169 -0
  47. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/_components/members-tabs.tsx +571 -0
  48. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/members/page.tsx +40 -0
  49. package/template/saas-starter/src/app/(app)/app/[orgId]/settings/page.tsx +69 -0
  50. package/template/saas-starter/src/app/(app)/app/_components/app-header.tsx +29 -0
  51. package/template/saas-starter/src/app/(app)/app/_components/app-search.tsx +17 -0
  52. package/template/saas-starter/src/app/(app)/app/_components/app-sidebar.tsx +27 -0
  53. package/template/saas-starter/src/app/(app)/app/_components/organization-context.tsx +41 -0
  54. package/template/saas-starter/src/app/(app)/app/_components/organization-switcher.tsx +140 -0
  55. package/template/saas-starter/src/app/(app)/app/layout.tsx +52 -0
  56. package/template/saas-starter/src/app/(app)/app/onboarding/_components/create-organization-form.tsx +129 -0
  57. package/template/saas-starter/src/app/(app)/app/onboarding/page.tsx +42 -0
  58. package/template/saas-starter/src/app/(app)/app/page.tsx +81 -0
  59. package/template/saas-starter/src/app/(auth)/login/email-login-form.tsx +109 -0
  60. package/template/saas-starter/src/app/(auth)/login/page.tsx +87 -0
  61. package/template/saas-starter/src/app/(auth)/login/social-login-buttons.tsx +75 -0
  62. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx +88 -0
  63. package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/page.tsx +167 -0
  64. package/template/saas-starter/src/app/(marketing)/layout.tsx +14 -0
  65. package/template/saas-starter/src/app/(marketing)/page.tsx +3 -0
  66. package/template/saas-starter/src/app/(marketing)/privacy/page.tsx +3 -0
  67. package/template/saas-starter/src/app/(marketing)/terms/page.tsx +3 -0
  68. package/template/saas-starter/src/app/api/auth/[...all]/route.ts +4 -0
  69. package/template/saas-starter/src/app/api/webhooks/stripe/route.ts +94 -0
  70. package/template/saas-starter/src/app/favicon.ico +0 -0
  71. package/template/saas-starter/src/app/globals.css +240 -0
  72. package/template/saas-starter/src/app/layout.tsx +53 -0
  73. package/template/saas-starter/src/app/not-found.tsx +27 -0
  74. package/template/saas-starter/src/app/proxy.ts +28 -0
  75. package/template/saas-starter/src/app/robots.ts +32 -0
  76. package/template/saas-starter/src/app/sitemap.ts +20 -0
  77. package/template/saas-starter/src/components/dashboard-header.tsx +64 -0
  78. package/template/saas-starter/src/components/data-table/enum.ts +5 -0
  79. package/template/saas-starter/src/components/data-table/index.tsx +960 -0
  80. package/template/saas-starter/src/components/data-table/types.ts +75 -0
  81. package/template/saas-starter/src/components/error.tsx +26 -0
  82. package/template/saas-starter/src/components/file-upload.tsx +215 -0
  83. package/template/saas-starter/src/components/impersonation-banner.tsx +44 -0
  84. package/template/saas-starter/src/components/loader.tsx +25 -0
  85. package/template/saas-starter/src/components/navigation-search.tsx +140 -0
  86. package/template/saas-starter/src/components/navigation-sidebar.tsx +241 -0
  87. package/template/saas-starter/src/components/page-heading.tsx +32 -0
  88. package/template/saas-starter/src/components/react-query-provider.tsx +12 -0
  89. package/template/saas-starter/src/components/screen-size.tsx +40 -0
  90. package/template/saas-starter/src/components/site-footer.tsx +23 -0
  91. package/template/saas-starter/src/components/theme-provider.tsx +67 -0
  92. package/template/saas-starter/src/components/theme-toggle.tsx +22 -0
  93. package/template/saas-starter/src/components/ui/avatar.tsx +112 -0
  94. package/template/saas-starter/src/components/ui/badge.tsx +49 -0
  95. package/template/saas-starter/src/components/ui/button.tsx +67 -0
  96. package/template/saas-starter/src/components/ui/card.tsx +103 -0
  97. package/template/saas-starter/src/components/ui/checkbox.tsx +32 -0
  98. package/template/saas-starter/src/components/ui/collapsible.tsx +33 -0
  99. package/template/saas-starter/src/components/ui/command.tsx +192 -0
  100. package/template/saas-starter/src/components/ui/dialog.tsx +164 -0
  101. package/template/saas-starter/src/components/ui/dropdown-menu.tsx +273 -0
  102. package/template/saas-starter/src/components/ui/field.tsx +238 -0
  103. package/template/saas-starter/src/components/ui/input-group.tsx +156 -0
  104. package/template/saas-starter/src/components/ui/input.tsx +19 -0
  105. package/template/saas-starter/src/components/ui/label.tsx +24 -0
  106. package/template/saas-starter/src/components/ui/popover.tsx +89 -0
  107. package/template/saas-starter/src/components/ui/select.tsx +195 -0
  108. package/template/saas-starter/src/components/ui/separator.tsx +28 -0
  109. package/template/saas-starter/src/components/ui/sheet.tsx +143 -0
  110. package/template/saas-starter/src/components/ui/sidebar.tsx +702 -0
  111. package/template/saas-starter/src/components/ui/skeleton.tsx +13 -0
  112. package/template/saas-starter/src/components/ui/sonner.tsx +45 -0
  113. package/template/saas-starter/src/components/ui/switch.tsx +33 -0
  114. package/template/saas-starter/src/components/ui/table.tsx +116 -0
  115. package/template/saas-starter/src/components/ui/tabs.tsx +90 -0
  116. package/template/saas-starter/src/components/ui/textarea.tsx +18 -0
  117. package/template/saas-starter/src/components/ui/tooltip.tsx +57 -0
  118. package/template/saas-starter/src/components/user-menu.tsx +90 -0
  119. package/template/saas-starter/src/db/common.ts +31 -0
  120. package/template/saas-starter/src/db/enums.ts +26 -0
  121. package/template/saas-starter/src/db/index.ts +51 -0
  122. package/template/saas-starter/src/db/relations.ts +75 -0
  123. package/template/saas-starter/src/db/schema.ts +191 -0
  124. package/template/saas-starter/src/emails/components/email-layout.tsx +63 -0
  125. package/template/saas-starter/src/emails/invitation.tsx +56 -0
  126. package/template/saas-starter/src/emails/magic-link.tsx +49 -0
  127. package/template/saas-starter/src/emails/test.tsx +32 -0
  128. package/template/saas-starter/src/emails/welcome.tsx +34 -0
  129. package/template/saas-starter/src/env.ts +66 -0
  130. package/template/saas-starter/src/hooks/use-mobile.ts +19 -0
  131. package/template/saas-starter/src/instrumentation.ts +26 -0
  132. package/template/saas-starter/src/lib/actions.ts +21 -0
  133. package/template/saas-starter/src/lib/admin-navigation.ts +31 -0
  134. package/template/saas-starter/src/lib/app-navigation.ts +59 -0
  135. package/template/saas-starter/src/lib/auth-client.ts +29 -0
  136. package/template/saas-starter/src/lib/auth-permissions.ts +22 -0
  137. package/template/saas-starter/src/lib/auth.ts +211 -0
  138. package/template/saas-starter/src/lib/billing.ts +117 -0
  139. package/template/saas-starter/src/lib/cn.ts +4 -0
  140. package/template/saas-starter/src/lib/constants.ts +22 -0
  141. package/template/saas-starter/src/lib/delay.ts +2 -0
  142. package/template/saas-starter/src/lib/email-service.ts +89 -0
  143. package/template/saas-starter/src/lib/format-currency.ts +27 -0
  144. package/template/saas-starter/src/lib/format-date.ts +12 -0
  145. package/template/saas-starter/src/lib/nodemailer.ts +43 -0
  146. package/template/saas-starter/src/lib/organization-access.ts +93 -0
  147. package/template/saas-starter/src/lib/organization-plan-limits.ts +38 -0
  148. package/template/saas-starter/src/lib/otel.ts +27 -0
  149. package/template/saas-starter/src/lib/plans.ts +63 -0
  150. package/template/saas-starter/src/lib/safe-action-client.ts +75 -0
  151. package/template/saas-starter/src/lib/safe-action.ts +67 -0
  152. package/template/saas-starter/src/lib/siteConfig.ts +4 -0
  153. package/template/saas-starter/src/lib/storage.ts +181 -0
  154. package/template/saas-starter/src/lib/stripe.ts +6 -0
  155. package/template/saas-starter/src/lib/toast-message.ts +15 -0
  156. package/template/saas-starter/src/lib/validations.ts +12 -0
  157. package/template/saas-starter/src/scripts/reset-db.ts +22 -0
  158. package/template/saas-starter/src/scripts/seed.ts +11 -0
  159. package/template/saas-starter/src/scripts/setup-stripe-plans.ts +200 -0
  160. package/template/saas-starter/src/scripts/test-mail.ts +35 -0
  161. package/template/saas-starter/tsconfig.json +51 -0
@@ -0,0 +1,364 @@
1
+ 'use client'
2
+
3
+ import { Loader2 } from 'lucide-react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { useState } from 'react'
6
+ import { transferOrganizationOwnershipAction } from '~/app/(app)/actions/organization'
7
+ import { Button } from '~/components/ui/button'
8
+ import {
9
+ Dialog,
10
+ DialogContent,
11
+ DialogDescription,
12
+ DialogFooter,
13
+ DialogHeader,
14
+ DialogTitle,
15
+ DialogTrigger
16
+ } from '~/components/ui/dialog'
17
+ import { Input } from '~/components/ui/input'
18
+ import { Label } from '~/components/ui/label'
19
+ import {
20
+ Select,
21
+ SelectContent,
22
+ SelectItem,
23
+ SelectTrigger,
24
+ SelectValue
25
+ } from '~/components/ui/select'
26
+ import { organization } from '~/lib/auth-client'
27
+ import { useSafeActionMutation } from '~/lib/safe-action-client'
28
+ import { toastErrorMessage, toastSuccessMessage } from '~/lib/toast-message'
29
+
30
+ type TransferTarget = {
31
+ memberId: string
32
+ name: string
33
+ email: string
34
+ role: string
35
+ }
36
+
37
+ export function DangerZone({
38
+ orgId,
39
+ orgName,
40
+ isOwner,
41
+ transferTargets
42
+ }: {
43
+ orgId: string
44
+ orgName: string
45
+ isOwner: boolean
46
+ transferTargets: TransferTarget[]
47
+ }) {
48
+ // The sole owner can't leave: ownership must first be handed to someone else.
49
+ const isSoleOwner =
50
+ isOwner && !transferTargets.some(target => target.role === 'owner')
51
+
52
+ return (
53
+ <section className="border-destructive/30 space-y-6 rounded-lg border p-6">
54
+ <div>
55
+ <h2 className="text-destructive text-lg font-semibold">Danger zone</h2>
56
+ <p className="text-muted-foreground text-sm">
57
+ These actions are irreversible. Proceed with care.
58
+ </p>
59
+ </div>
60
+
61
+ {isOwner && transferTargets.length > 0 ? (
62
+ <TransferOwnershipRow
63
+ orgId={orgId}
64
+ isOwner={isOwner}
65
+ transferTargets={transferTargets}
66
+ />
67
+ ) : null}
68
+
69
+ <LeaveOrganizationRow
70
+ orgId={orgId}
71
+ orgName={orgName}
72
+ isSoleOwner={isSoleOwner}
73
+ />
74
+
75
+ {isOwner ? (
76
+ <DeleteOrganizationRow orgId={orgId} orgName={orgName} />
77
+ ) : null}
78
+ </section>
79
+ )
80
+ }
81
+
82
+ function Row({
83
+ title,
84
+ description,
85
+ children
86
+ }: {
87
+ title: string
88
+ description: string
89
+ children: React.ReactNode
90
+ }) {
91
+ return (
92
+ <div className="flex flex-col gap-3 border-t pt-6 sm:flex-row sm:items-center sm:justify-between">
93
+ <div className="space-y-0.5">
94
+ <p className="text-sm font-medium">{title}</p>
95
+ <p className="text-muted-foreground text-sm">{description}</p>
96
+ </div>
97
+ <div className="shrink-0">{children}</div>
98
+ </div>
99
+ )
100
+ }
101
+
102
+ function TransferOwnershipRow({
103
+ orgId,
104
+ isOwner,
105
+ transferTargets
106
+ }: {
107
+ orgId: string
108
+ isOwner: boolean
109
+ transferTargets: TransferTarget[]
110
+ }) {
111
+ const router = useRouter()
112
+ const [open, setOpen] = useState(false)
113
+ const [targetId, setTargetId] = useState('')
114
+
115
+ const transferOwnership = useSafeActionMutation(
116
+ transferOrganizationOwnershipAction,
117
+ {
118
+ onSuccess: () => {
119
+ toastSuccessMessage('Ownership transferred')
120
+ setOpen(false)
121
+ router.refresh()
122
+ },
123
+ onError: error => {
124
+ toastErrorMessage(error.message ?? 'Failed to transfer ownership')
125
+ }
126
+ }
127
+ )
128
+
129
+ function handleTransfer() {
130
+ if (!isOwner) {
131
+ toastErrorMessage('Only the organization owner can transfer ownership.')
132
+ return
133
+ }
134
+ if (!targetId) return
135
+
136
+ transferOwnership.mutate({
137
+ organizationId: orgId,
138
+ targetMemberId: targetId
139
+ })
140
+ }
141
+
142
+ return (
143
+ <Row
144
+ title="Transfer ownership"
145
+ description="Make another member the owner. You'll become an admin."
146
+ >
147
+ <Dialog open={open} onOpenChange={setOpen}>
148
+ <DialogTrigger asChild>
149
+ <Button variant="outline">Transfer</Button>
150
+ </DialogTrigger>
151
+ <DialogContent className="sm:max-w-md">
152
+ <DialogHeader>
153
+ <DialogTitle>Transfer ownership</DialogTitle>
154
+ <DialogDescription>
155
+ Select the member who should become the new owner. You&apos;ll be
156
+ demoted to admin.
157
+ </DialogDescription>
158
+ </DialogHeader>
159
+
160
+ <div className="space-y-2 py-1">
161
+ <Label>New owner</Label>
162
+ <Select value={targetId} onValueChange={setTargetId}>
163
+ <SelectTrigger className="w-full">
164
+ <SelectValue placeholder="Select a member" />
165
+ </SelectTrigger>
166
+ <SelectContent>
167
+ {transferTargets.map(target => (
168
+ <SelectItem key={target.memberId} value={target.memberId}>
169
+ {target.name} ({target.email})
170
+ </SelectItem>
171
+ ))}
172
+ </SelectContent>
173
+ </Select>
174
+ </div>
175
+
176
+ <DialogFooter>
177
+ <Button
178
+ variant="outline"
179
+ onClick={() => setOpen(false)}
180
+ disabled={transferOwnership.isPending}
181
+ >
182
+ Cancel
183
+ </Button>
184
+ <Button
185
+ onClick={handleTransfer}
186
+ disabled={transferOwnership.isPending || !targetId}
187
+ >
188
+ {transferOwnership.isPending && (
189
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
190
+ )}
191
+ Transfer ownership
192
+ </Button>
193
+ </DialogFooter>
194
+ </DialogContent>
195
+ </Dialog>
196
+ </Row>
197
+ )
198
+ }
199
+
200
+ function LeaveOrganizationRow({
201
+ orgId,
202
+ orgName,
203
+ isSoleOwner
204
+ }: {
205
+ orgId: string
206
+ orgName: string
207
+ isSoleOwner: boolean
208
+ }) {
209
+ const router = useRouter()
210
+ const [open, setOpen] = useState(false)
211
+ const [submitting, setSubmitting] = useState(false)
212
+
213
+ async function handleLeave() {
214
+ // Guard: the only owner must transfer ownership before leaving.
215
+ if (isSoleOwner) {
216
+ toastErrorMessage(
217
+ 'Transfer ownership to another member before leaving this organization.'
218
+ )
219
+ return
220
+ }
221
+
222
+ setSubmitting(true)
223
+ const { error } = await organization.leave({ organizationId: orgId })
224
+
225
+ if (error) {
226
+ toastErrorMessage(error.message ?? 'Failed to leave organization')
227
+ setSubmitting(false)
228
+ return
229
+ }
230
+
231
+ toastSuccessMessage(`You left ${orgName}`)
232
+ router.push('/app')
233
+ }
234
+
235
+ return (
236
+ <Row
237
+ title="Leave organization"
238
+ description="Remove yourself from this organization."
239
+ >
240
+ <Dialog open={open} onOpenChange={setOpen}>
241
+ <DialogTrigger asChild>
242
+ <Button variant="outline">Leave</Button>
243
+ </DialogTrigger>
244
+ <DialogContent className="sm:max-w-md">
245
+ <DialogHeader>
246
+ <DialogTitle>
247
+ {isSoleOwner ? 'Transfer ownership first' : `Leave ${orgName}?`}
248
+ </DialogTitle>
249
+ <DialogDescription>
250
+ {isSoleOwner
251
+ ? "You're the only owner of this organization. Make another member the owner before you leave - or delete the organization instead."
252
+ : "You'll lose access to this organization. An owner or admin will need to invite you back."}
253
+ </DialogDescription>
254
+ </DialogHeader>
255
+ <DialogFooter>
256
+ <Button
257
+ variant="outline"
258
+ onClick={() => setOpen(false)}
259
+ disabled={submitting}
260
+ >
261
+ {isSoleOwner ? 'Close' : 'Cancel'}
262
+ </Button>
263
+ {!isSoleOwner ? (
264
+ <Button
265
+ variant="destructive"
266
+ onClick={handleLeave}
267
+ disabled={submitting}
268
+ >
269
+ {submitting && (
270
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
271
+ )}
272
+ Leave organization
273
+ </Button>
274
+ ) : null}
275
+ </DialogFooter>
276
+ </DialogContent>
277
+ </Dialog>
278
+ </Row>
279
+ )
280
+ }
281
+
282
+ function DeleteOrganizationRow({
283
+ orgId,
284
+ orgName
285
+ }: {
286
+ orgId: string
287
+ orgName: string
288
+ }) {
289
+ const router = useRouter()
290
+ const [open, setOpen] = useState(false)
291
+ const [confirmText, setConfirmText] = useState('')
292
+ const [submitting, setSubmitting] = useState(false)
293
+
294
+ async function handleDelete() {
295
+ setSubmitting(true)
296
+ const { error } = await organization.delete({ organizationId: orgId })
297
+
298
+ if (error) {
299
+ toastErrorMessage(error.message ?? 'Failed to delete organization')
300
+ setSubmitting(false)
301
+ return
302
+ }
303
+
304
+ toastSuccessMessage(`${orgName} deleted`)
305
+ router.push('/app')
306
+ }
307
+
308
+ return (
309
+ <Row
310
+ title="Delete organization"
311
+ description="Permanently delete this organization and all of its data."
312
+ >
313
+ <Dialog
314
+ open={open}
315
+ onOpenChange={next => {
316
+ setOpen(next)
317
+ if (!next) setConfirmText('')
318
+ }}
319
+ >
320
+ <DialogTrigger asChild>
321
+ <Button variant="destructive">Delete</Button>
322
+ </DialogTrigger>
323
+ <DialogContent className="sm:max-w-md">
324
+ <DialogHeader>
325
+ <DialogTitle>Delete {orgName}?</DialogTitle>
326
+ <DialogDescription>
327
+ This cannot be undone. All members, invitations and data for this
328
+ organization will be permanently removed.
329
+ </DialogDescription>
330
+ </DialogHeader>
331
+
332
+ <div className="space-y-2 py-1">
333
+ <Label>
334
+ Type <span className="font-semibold">{orgName}</span> to confirm
335
+ </Label>
336
+ <Input
337
+ value={confirmText}
338
+ onChange={e => setConfirmText(e.target.value)}
339
+ placeholder={orgName}
340
+ />
341
+ </div>
342
+
343
+ <DialogFooter>
344
+ <Button
345
+ variant="outline"
346
+ onClick={() => setOpen(false)}
347
+ disabled={submitting}
348
+ >
349
+ Cancel
350
+ </Button>
351
+ <Button
352
+ variant="destructive"
353
+ onClick={handleDelete}
354
+ disabled={submitting || confirmText !== orgName}
355
+ >
356
+ {submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
357
+ Delete organization
358
+ </Button>
359
+ </DialogFooter>
360
+ </DialogContent>
361
+ </Dialog>
362
+ </Row>
363
+ )
364
+ }
@@ -0,0 +1,155 @@
1
+ 'use client'
2
+
3
+ import { zodResolver } from '@hookform/resolvers/zod'
4
+ import { Loader2 } from 'lucide-react'
5
+ import { useRouter } from 'next/navigation'
6
+ import { useState } from 'react'
7
+ import { useForm } from 'react-hook-form'
8
+ import { z } from 'zod'
9
+ import { updateOrganizationLogoAction } from '~/app/(app)/actions/organization'
10
+ import { FileUpload } from '~/components/file-upload'
11
+ import { Button } from '~/components/ui/button'
12
+ import {
13
+ Field,
14
+ FieldContent,
15
+ FieldDescription,
16
+ FieldError,
17
+ FieldTitle
18
+ } from '~/components/ui/field'
19
+ import { Input } from '~/components/ui/input'
20
+ import { organization } from '~/lib/auth-client'
21
+ import { useSafeActionMutation } from '~/lib/safe-action-client'
22
+ import { toastErrorMessage, toastSuccessMessage } from '~/lib/toast-message'
23
+ import { stringValidation } from '~/lib/validations'
24
+
25
+ const schema = z.object({
26
+ name: stringValidation,
27
+ slug: z
28
+ .string()
29
+ .trim()
30
+ .min(1, 'Slug is required')
31
+ .max(255)
32
+ .regex(
33
+ /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
34
+ 'Use lowercase letters, numbers and hyphens only'
35
+ )
36
+ })
37
+
38
+ type Form = z.infer<typeof schema>
39
+
40
+ export function GeneralSettingsForm({
41
+ orgId,
42
+ defaultName,
43
+ defaultSlug,
44
+ currentLogoUrl,
45
+ currentLogoKey
46
+ }: {
47
+ orgId: string
48
+ defaultName: string
49
+ defaultSlug: string
50
+ currentLogoUrl: string | null
51
+ currentLogoKey: string | null
52
+ }) {
53
+ const router = useRouter()
54
+ const [logo, setLogo] = useState<string | null>(currentLogoKey)
55
+ const [submitting, setSubmitting] = useState(false)
56
+ const { mutateAsync: updateOrgLogo } = useSafeActionMutation(
57
+ updateOrganizationLogoAction
58
+ )
59
+
60
+ const {
61
+ register,
62
+ handleSubmit,
63
+ formState: { errors, isDirty }
64
+ } = useForm<Form>({
65
+ resolver: zodResolver(schema),
66
+ defaultValues: { name: defaultName, slug: defaultSlug }
67
+ })
68
+
69
+ const logoChanged = logo !== currentLogoKey
70
+
71
+ async function onSubmit(values: Form) {
72
+ setSubmitting(true)
73
+
74
+ const { error } = await organization.update({
75
+ organizationId: orgId,
76
+ data: {
77
+ name: values.name,
78
+ slug: values.slug,
79
+ logo: logo ?? undefined
80
+ }
81
+ })
82
+
83
+ if (error) {
84
+ toastErrorMessage(error.message ?? 'Failed to update organization')
85
+ setSubmitting(false)
86
+ return
87
+ }
88
+
89
+ if (logoChanged && logo) {
90
+ await updateOrgLogo({
91
+ organizationId: orgId,
92
+ key: logo,
93
+ oldKey: currentLogoKey
94
+ })
95
+ }
96
+
97
+ toastSuccessMessage('Organization updated')
98
+ setSubmitting(false)
99
+ router.refresh()
100
+ }
101
+
102
+ return (
103
+ <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
104
+ <Field orientation="vertical">
105
+ <FieldContent>
106
+ <FieldTitle className="text-foreground mb-1.5 text-[13px] font-semibold">
107
+ Organization name
108
+ </FieldTitle>
109
+ <Input {...register('name')} aria-invalid={!!errors.name} />
110
+ <FieldError errors={[errors.name]} className="mt-1.5 text-[13px]" />
111
+ </FieldContent>
112
+ </Field>
113
+
114
+ <Field orientation="vertical">
115
+ <FieldContent>
116
+ <FieldTitle className="text-foreground mb-1.5 text-[13px] font-semibold">
117
+ Slug
118
+ </FieldTitle>
119
+ <Input {...register('slug')} aria-invalid={!!errors.slug} />
120
+ <FieldDescription className="mt-1.5 text-[13px]">
121
+ A unique identifier used in links.
122
+ </FieldDescription>
123
+ <FieldError errors={[errors.slug]} className="mt-1.5 text-[13px]" />
124
+ </FieldContent>
125
+ </Field>
126
+
127
+ <Field orientation="vertical">
128
+ <FieldContent>
129
+ <FieldTitle className="text-foreground mb-1.5 text-[13px] font-semibold">
130
+ Logo
131
+ </FieldTitle>
132
+ <div className="w-32">
133
+ <FileUpload
134
+ organizationId={orgId}
135
+ accept="image/*"
136
+ onClientUploadFinish={setLogo}
137
+ currentUrl={currentLogoUrl}
138
+ sizes="128px"
139
+ />
140
+ </div>
141
+ </FieldContent>
142
+ </Field>
143
+
144
+ <div>
145
+ <Button
146
+ type="submit"
147
+ disabled={submitting || (!isDirty && !logoChanged)}
148
+ >
149
+ {submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
150
+ Save changes
151
+ </Button>
152
+ </div>
153
+ </form>
154
+ )
155
+ }