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,17 @@
1
+ 'use client'
2
+
3
+ import { FC } from 'react'
4
+ import { NavigationSearch } from '~/components/navigation-search'
5
+ import { getAppNavigation } from '~/lib/app-navigation'
6
+
7
+ export const AppSearch: FC<{ orgId: string; role: string }> = ({
8
+ orgId,
9
+ role
10
+ }) => {
11
+ return (
12
+ <NavigationSearch
13
+ groups={getAppNavigation(orgId, role)}
14
+ dialogDescription="Search across this organization's pages and settings."
15
+ />
16
+ )
17
+ }
@@ -0,0 +1,27 @@
1
+ 'use client'
2
+
3
+ import { useParams } from 'next/navigation'
4
+ import { NavigationSidebar } from '~/components/navigation-sidebar'
5
+ import { getAppNavigation, type UserOrganization } from '~/lib/app-navigation'
6
+ import { OrganizationSwitcher } from './organization-switcher'
7
+
8
+ export function AppSidebar({
9
+ organizations
10
+ }: {
11
+ organizations: UserOrganization[]
12
+ }) {
13
+ const params = useParams()
14
+ const orgId = params?.orgId as string | undefined
15
+ const role = organizations.find(o => o.id === orgId)?.role
16
+
17
+ if (!orgId || !role) {
18
+ return null
19
+ }
20
+
21
+ return (
22
+ <NavigationSidebar
23
+ groups={getAppNavigation(orgId, role)}
24
+ header={<OrganizationSwitcher organizations={organizations} />}
25
+ />
26
+ )
27
+ }
@@ -0,0 +1,41 @@
1
+ 'use client'
2
+
3
+ import { createContext, useContext } from 'react'
4
+
5
+ type OrganizationContextType = {
6
+ id: string
7
+ name: string
8
+ role: string
9
+ memberId: string
10
+ userId: string
11
+ }
12
+
13
+ const OrganizationContext = createContext<OrganizationContextType>({
14
+ id: '',
15
+ name: '',
16
+ role: 'member',
17
+ memberId: '',
18
+ userId: ''
19
+ })
20
+
21
+ export const OrganizationContextProvider = ({
22
+ children,
23
+ value
24
+ }: {
25
+ children: React.ReactNode
26
+ value: OrganizationContextType
27
+ }) => {
28
+ return <OrganizationContext value={value}>{children}</OrganizationContext>
29
+ }
30
+
31
+ export const useOrganizationContext = () => {
32
+ const context = useContext(OrganizationContext)
33
+
34
+ if (!context) {
35
+ throw new Error(
36
+ 'useOrganizationContext must be used within a OrganizationContextProvider'
37
+ )
38
+ }
39
+
40
+ return context
41
+ }
@@ -0,0 +1,140 @@
1
+ 'use client'
2
+
3
+ import { Building2, ChevronsUpDown, PlusCircle } from 'lucide-react'
4
+ import Image from 'next/image'
5
+ import { useParams, useRouter } from 'next/navigation'
6
+ import { useMemo } from 'react'
7
+ import {
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuLabel,
12
+ DropdownMenuSeparator,
13
+ DropdownMenuTrigger
14
+ } from '~/components/ui/dropdown-menu'
15
+ import {
16
+ SidebarMenu,
17
+ SidebarMenuButton,
18
+ SidebarMenuItem
19
+ } from '~/components/ui/sidebar'
20
+ import type { UserOrganization } from '~/lib/app-navigation'
21
+
22
+ function OrganizationLogo({
23
+ logo,
24
+ name,
25
+ size
26
+ }: {
27
+ logo: string | null
28
+ name: string
29
+ size: 'sm' | 'lg'
30
+ }) {
31
+ if (logo) {
32
+ return (
33
+ <div
34
+ className={
35
+ size === 'lg'
36
+ ? 'relative aspect-square size-8 shrink-0 overflow-hidden rounded-lg'
37
+ : 'relative size-full overflow-hidden rounded-sm'
38
+ }
39
+ >
40
+ <Image
41
+ src={logo}
42
+ alt={name}
43
+ fill
44
+ sizes={size === 'lg' ? '32px' : '24px'}
45
+ className="object-cover"
46
+ unoptimized={logo.startsWith('data:')}
47
+ />
48
+ </div>
49
+ )
50
+ }
51
+
52
+ return size === 'lg' ? (
53
+ <div className="bg-primary text-primary-foreground flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg">
54
+ <Building2 className="size-4" />
55
+ </div>
56
+ ) : (
57
+ <Building2 className="size-4 shrink-0" />
58
+ )
59
+ }
60
+
61
+ export function OrganizationSwitcher({
62
+ organizations
63
+ }: {
64
+ organizations: UserOrganization[]
65
+ }) {
66
+ const router = useRouter()
67
+ const params = useParams()
68
+ const activeOrgId = params?.orgId as string | undefined
69
+
70
+ const activeOrg = useMemo(
71
+ () => organizations.find(o => o.id === activeOrgId) ?? organizations[0],
72
+ [organizations, activeOrgId]
73
+ )
74
+
75
+ return (
76
+ <SidebarMenu>
77
+ <SidebarMenuItem>
78
+ <DropdownMenu>
79
+ <DropdownMenuTrigger asChild>
80
+ <SidebarMenuButton
81
+ size="lg"
82
+ className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
83
+ >
84
+ <OrganizationLogo
85
+ logo={activeOrg?.logo ?? null}
86
+ name={activeOrg?.name ?? ''}
87
+ size="lg"
88
+ />
89
+ <div className="grid flex-1 text-left text-sm leading-tight">
90
+ <span className="truncate font-medium">
91
+ {activeOrg?.name ?? 'Select organization'}
92
+ </span>
93
+ {activeOrg ? (
94
+ <span className="text-muted-foreground truncate text-xs capitalize">
95
+ {activeOrg.role}
96
+ </span>
97
+ ) : null}
98
+ </div>
99
+ <ChevronsUpDown className="ml-auto size-4 shrink-0" />
100
+ </SidebarMenuButton>
101
+ </DropdownMenuTrigger>
102
+ <DropdownMenuContent
103
+ className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
104
+ align="start"
105
+ side="bottom"
106
+ sideOffset={4}
107
+ >
108
+ <DropdownMenuLabel className="text-muted-foreground text-xs">
109
+ Organizations
110
+ </DropdownMenuLabel>
111
+ {organizations.map(org => (
112
+ <DropdownMenuItem
113
+ key={org.id}
114
+ onClick={() => router.push(`/app/${org.id}/dashboard`)}
115
+ className="gap-2 p-2"
116
+ >
117
+ <div className="flex size-6 items-center justify-center overflow-hidden rounded-sm border">
118
+ <OrganizationLogo logo={org.logo} name={org.name} size="sm" />
119
+ </div>
120
+ <span className="truncate">{org.name}</span>
121
+ </DropdownMenuItem>
122
+ ))}
123
+ <DropdownMenuSeparator />
124
+ <DropdownMenuItem
125
+ className="gap-2 p-2"
126
+ onClick={() => router.push('/app/onboarding')}
127
+ >
128
+ <div className="bg-background text-muted-foreground flex size-6 items-center justify-center rounded-md border">
129
+ <PlusCircle className="size-4" />
130
+ </div>
131
+ <div className="text-muted-foreground font-medium">
132
+ Create organization
133
+ </div>
134
+ </DropdownMenuItem>
135
+ </DropdownMenuContent>
136
+ </DropdownMenu>
137
+ </SidebarMenuItem>
138
+ </SidebarMenu>
139
+ )
140
+ }
@@ -0,0 +1,52 @@
1
+ import { cookies } from 'next/headers'
2
+ import { redirect } from 'next/navigation'
3
+ import { NuqsAdapter } from 'nuqs/adapters/react'
4
+ import { ImpersonationBanner } from '~/components/impersonation-banner'
5
+ import { SidebarInset, SidebarProvider } from '~/components/ui/sidebar'
6
+ import { TooltipProvider } from '~/components/ui/tooltip'
7
+ import { getAuthSession } from '~/lib/auth'
8
+ import { getUserOrganizationsCached } from '~/lib/organization-access'
9
+ import { AppHeader } from './_components/app-header'
10
+ import { AppSidebar } from './_components/app-sidebar'
11
+
12
+ export default async function Layout({
13
+ children
14
+ }: Readonly<{
15
+ children: React.ReactNode
16
+ }>) {
17
+ const authSession = await getAuthSession()
18
+
19
+ if (!authSession) {
20
+ return redirect('/login')
21
+ }
22
+
23
+ const cookieStore = await cookies()
24
+ const sidebarStateCookie = cookieStore.get('sidebar_state')?.value
25
+ const defaultOpen = sidebarStateCookie !== 'false'
26
+
27
+ const organizations = await getUserOrganizationsCached()
28
+
29
+ return (
30
+ <TooltipProvider>
31
+ <NuqsAdapter>
32
+ <SidebarProvider defaultOpen={defaultOpen}>
33
+ <AppSidebar organizations={organizations} />
34
+ <SidebarInset>
35
+ {authSession.session.impersonatedBy ? (
36
+ <ImpersonationBanner />
37
+ ) : null}
38
+ <AppHeader
39
+ user={{
40
+ name: authSession.user.name,
41
+ email: authSession.user.email,
42
+ image: authSession.user.image
43
+ }}
44
+ organizations={organizations}
45
+ />
46
+ <div className="flex flex-1 flex-col gap-4 p-4">{children}</div>
47
+ </SidebarInset>
48
+ </SidebarProvider>
49
+ </NuqsAdapter>
50
+ </TooltipProvider>
51
+ )
52
+ }
@@ -0,0 +1,129 @@
1
+ 'use client'
2
+
3
+ import { zodResolver } from '@hookform/resolvers/zod'
4
+ import { Loader2 } from 'lucide-react'
5
+ import { useState } from 'react'
6
+ import { useForm } from 'react-hook-form'
7
+ import { z } from 'zod'
8
+ import { Button } from '~/components/ui/button'
9
+ import {
10
+ Field,
11
+ FieldContent,
12
+ FieldDescription,
13
+ FieldError,
14
+ FieldTitle
15
+ } from '~/components/ui/field'
16
+ import { Input } from '~/components/ui/input'
17
+ import { organization } from '~/lib/auth-client'
18
+ import { toastErrorMessage, toastSuccessMessage } from '~/lib/toast-message'
19
+ import { stringValidation } from '~/lib/validations'
20
+
21
+ const slugify = (value: string) =>
22
+ value
23
+ .toLowerCase()
24
+ .trim()
25
+ .replace(/[^a-z0-9]+/g, '-')
26
+ .replace(/^-+|-+$/g, '')
27
+
28
+ const schema = z.object({
29
+ name: stringValidation,
30
+ slug: z
31
+ .string()
32
+ .trim()
33
+ .min(1, 'Slug is required')
34
+ .max(255)
35
+ .regex(
36
+ /^[a-z0-9]+(?:-[a-z0-9]+)*$/,
37
+ 'Use lowercase letters, numbers and hyphens only'
38
+ )
39
+ })
40
+
41
+ type Form = z.infer<typeof schema>
42
+
43
+ export function CreateOrganizationForm() {
44
+ const [submitting, setSubmitting] = useState(false)
45
+
46
+ const {
47
+ register,
48
+ handleSubmit,
49
+ setValue,
50
+ formState: { errors }
51
+ } = useForm<Form>({
52
+ resolver: zodResolver(schema),
53
+ defaultValues: { name: '', slug: '' }
54
+ })
55
+
56
+ const [slugEdited, setSlugEdited] = useState(false)
57
+
58
+ async function onSubmit(values: Form) {
59
+ setSubmitting(true)
60
+
61
+ const { data, error } = await organization.create({
62
+ name: values.name,
63
+ slug: values.slug
64
+ })
65
+
66
+ if (error || !data) {
67
+ toastErrorMessage(error?.message ?? 'Failed to create organization')
68
+ setSubmitting(false)
69
+ return
70
+ }
71
+
72
+ toastSuccessMessage(`${data.name} created`)
73
+ window.location.assign(`/app/${data.id}/dashboard`)
74
+ }
75
+
76
+ return (
77
+ <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col gap-6">
78
+ <Field orientation="vertical">
79
+ <FieldContent>
80
+ <FieldTitle className="text-foreground mb-1.5 text-[13px] font-semibold">
81
+ Organization name
82
+ </FieldTitle>
83
+ <Input
84
+ autoFocus
85
+ placeholder="Acme Inc."
86
+ {...register('name', {
87
+ onChange: e => {
88
+ if (!slugEdited) {
89
+ setValue('slug', slugify(e.target.value), {
90
+ shouldValidate: true
91
+ })
92
+ }
93
+ }
94
+ })}
95
+ aria-invalid={!!errors.name}
96
+ />
97
+ <FieldError errors={[errors.name]} className="mt-1.5 text-[13px]" />
98
+ </FieldContent>
99
+ </Field>
100
+
101
+ <Field orientation="vertical">
102
+ <FieldContent>
103
+ <FieldTitle className="text-foreground mb-1.5 text-[13px] font-semibold">
104
+ Slug
105
+ </FieldTitle>
106
+ <Input
107
+ placeholder="acme-inc"
108
+ {...register('slug', {
109
+ onChange: () => setSlugEdited(true)
110
+ })}
111
+ aria-invalid={!!errors.slug}
112
+ />
113
+ <FieldDescription className="mt-1.5 text-[13px]">
114
+ A unique identifier used in links. Lowercase letters, numbers and
115
+ hyphens.
116
+ </FieldDescription>
117
+ <FieldError errors={[errors.slug]} className="mt-1.5 text-[13px]" />
118
+ </FieldContent>
119
+ </Field>
120
+
121
+ <div>
122
+ <Button type="submit" disabled={submitting}>
123
+ {submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
124
+ Create organization
125
+ </Button>
126
+ </div>
127
+ </form>
128
+ )
129
+ }
@@ -0,0 +1,42 @@
1
+ import { ArrowLeft } from 'lucide-react'
2
+ import Link from 'next/link'
3
+ import { Button } from '~/components/ui/button'
4
+ import { getUserOrganizationsCached } from '~/lib/organization-access'
5
+ import { CreateOrganizationForm } from './_components/create-organization-form'
6
+
7
+ export default async function Page() {
8
+ const organizations = await getUserOrganizationsCached()
9
+ const isFirstOrg = organizations.length === 0
10
+
11
+ return (
12
+ <div className="mx-auto flex w-full max-w-xl flex-col gap-8 px-2 py-8">
13
+ <div className="flex flex-col gap-2">
14
+ {!isFirstOrg ? (
15
+ <Button
16
+ asChild
17
+ variant="ghost"
18
+ size="sm"
19
+ className="text-muted-foreground -ml-2 w-fit"
20
+ >
21
+ <Link href="/app">
22
+ <ArrowLeft className="mr-2 size-4" />
23
+ Back to organizations
24
+ </Link>
25
+ </Button>
26
+ ) : null}
27
+ <h1 className="text-3xl font-bold tracking-tight">
28
+ {isFirstOrg
29
+ ? 'Create your organization'
30
+ : 'Create a new organization'}
31
+ </h1>
32
+ <p className="text-muted-foreground">
33
+ {isFirstOrg
34
+ ? 'Set up an organization to get started. You can invite teammates afterwards.'
35
+ : 'Spin up another workspace. You can switch between organizations at any time.'}
36
+ </p>
37
+ </div>
38
+
39
+ <CreateOrganizationForm />
40
+ </div>
41
+ )
42
+ }
@@ -0,0 +1,81 @@
1
+ import { Building2, Plus } from 'lucide-react'
2
+ import Image from 'next/image'
3
+ import Link from 'next/link'
4
+ import { redirect } from 'next/navigation'
5
+ import {
6
+ Card,
7
+ CardDescription,
8
+ CardHeader,
9
+ CardTitle
10
+ } from '~/components/ui/card'
11
+ import { getUserOrganizationsCached } from '~/lib/organization-access'
12
+ import { siteConfig } from '~/lib/siteConfig'
13
+
14
+ export default async function Page() {
15
+ const organizations = await getUserOrganizationsCached()
16
+
17
+ if (organizations.length === 0) {
18
+ return redirect('/app/onboarding')
19
+ }
20
+
21
+ return (
22
+ <div className="mx-auto flex w-full max-w-4xl flex-col gap-8 px-2 py-8">
23
+ <div className="flex flex-col gap-2">
24
+ <h1 className="text-3xl font-bold tracking-tight">
25
+ Welcome to {siteConfig.name}
26
+ </h1>
27
+ <p className="text-muted-foreground">
28
+ Select an organization to manage, or create a new one.
29
+ </p>
30
+ </div>
31
+
32
+ <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
33
+ {organizations.map(org => (
34
+ <Link
35
+ key={org.id}
36
+ href={`/app/${org.id}/dashboard`}
37
+ className="group"
38
+ >
39
+ <Card className="hover:border-primary h-full cursor-pointer transition-all duration-200 hover:shadow-md">
40
+ <CardHeader className="flex flex-row items-center gap-4">
41
+ {org.logo ? (
42
+ <div className="relative size-10 shrink-0 overflow-hidden rounded-lg">
43
+ <Image
44
+ src={org.logo}
45
+ alt={org.name}
46
+ fill
47
+ sizes="40px"
48
+ className="object-cover"
49
+ unoptimized={org.logo.startsWith('data:')}
50
+ />
51
+ </div>
52
+ ) : (
53
+ <div className="bg-primary/10 text-primary group-hover:bg-primary group-hover:text-primary-foreground flex size-10 shrink-0 items-center justify-center rounded-lg transition-colors">
54
+ <Building2 className="size-5" />
55
+ </div>
56
+ )}
57
+ <div className="min-w-0 flex-1">
58
+ <CardTitle className="truncate text-lg">{org.name}</CardTitle>
59
+ <CardDescription className="truncate text-xs capitalize">
60
+ {org.role}
61
+ </CardDescription>
62
+ </div>
63
+ </CardHeader>
64
+ </Card>
65
+ </Link>
66
+ ))}
67
+
68
+ <Link href="/app/onboarding" className="group">
69
+ <Card className="hover:border-primary hover:bg-muted/50 flex h-full min-h-28 cursor-pointer flex-col items-center justify-center border-dashed transition-all">
70
+ <div className="bg-muted mb-2 flex size-10 items-center justify-center rounded-full">
71
+ <Plus className="size-5" />
72
+ </div>
73
+ <div className="text-muted-foreground font-semibold">
74
+ Create organization
75
+ </div>
76
+ </Card>
77
+ </Link>
78
+ </div>
79
+ </div>
80
+ )
81
+ }
@@ -0,0 +1,109 @@
1
+ 'use client'
2
+
3
+ import { zodResolver } from '@hookform/resolvers/zod'
4
+ import { Mail } from 'lucide-react'
5
+ import { useState } from 'react'
6
+ import { Controller, useForm } from 'react-hook-form'
7
+ import { z } from 'zod'
8
+ import { Button } from '~/components/ui/button'
9
+ import {
10
+ Field,
11
+ FieldContent,
12
+ FieldError,
13
+ FieldLabel
14
+ } from '~/components/ui/field'
15
+ import { Input } from '~/components/ui/input'
16
+ import { signIn } from '~/lib/auth-client'
17
+ import { toastErrorMessage, toastSuccessMessage } from '~/lib/toast-message'
18
+
19
+ const emailLoginFormSchema = z.object({
20
+ email: z.email()
21
+ })
22
+
23
+ export const EmailLoginForm = ({
24
+ callbackUrl
25
+ }: {
26
+ callbackUrl?: string | null
27
+ }) => {
28
+ const form = useForm<z.infer<typeof emailLoginFormSchema>>({
29
+ resolver: zodResolver(emailLoginFormSchema),
30
+ defaultValues: {
31
+ email: ''
32
+ }
33
+ })
34
+
35
+ const [isLoading, setIsLoading] = useState(false)
36
+ const [isSent, setIsSent] = useState('')
37
+
38
+ async function onSubmit(values: z.infer<typeof emailLoginFormSchema>) {
39
+ const email = values.email
40
+
41
+ if (!email) return
42
+
43
+ setIsLoading(true)
44
+ try {
45
+ const res = await signIn.magicLink({
46
+ email,
47
+ name: '',
48
+ callbackURL: callbackUrl || '/login'
49
+ })
50
+ if (res?.error) throw res.error
51
+ setIsSent(email)
52
+ toastSuccessMessage('Magic link sent! Check your email.')
53
+ } catch (err) {
54
+ console.error('Error sending magic link:', err)
55
+ if (
56
+ typeof err === 'object' &&
57
+ err &&
58
+ 'message' in err &&
59
+ typeof err.message === 'string'
60
+ )
61
+ toastErrorMessage(err.message)
62
+ else toastErrorMessage('Something went wrong. Please try again.')
63
+ } finally {
64
+ setIsLoading(false)
65
+ }
66
+ }
67
+
68
+ if (isSent) {
69
+ return (
70
+ <div className="rounded-lg border p-4 text-center">
71
+ <Mail className="text-primary mx-auto mb-2 size-6" />
72
+ <h3 className="mb-1 text-sm font-medium">Check your email</h3>
73
+ <p className="text-muted-foreground text-xs">
74
+ We&apos;ve sent a magic link to <strong>{isSent}</strong>
75
+ </p>
76
+ </div>
77
+ )
78
+ }
79
+
80
+ return (
81
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
82
+ <Controller
83
+ control={form.control}
84
+ name="email"
85
+ render={({ field, fieldState }) => (
86
+ <Field data-invalid={fieldState.invalid}>
87
+ <FieldLabel htmlFor="email">Email</FieldLabel>
88
+ <FieldContent>
89
+ <Input
90
+ id="email"
91
+ type="email"
92
+ placeholder="name@example.com"
93
+ autoCapitalize="off"
94
+ autoComplete="email"
95
+ autoFocus
96
+ aria-invalid={fieldState.invalid}
97
+ {...field}
98
+ />
99
+ <FieldError errors={[fieldState.error]} />
100
+ </FieldContent>
101
+ </Field>
102
+ )}
103
+ />
104
+ <Button type="submit" className="w-full" disabled={isLoading}>
105
+ {isLoading ? 'Sending...' : 'Send Magic Link'}
106
+ </Button>
107
+ </form>
108
+ )
109
+ }