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