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,87 @@
|
|
|
1
|
+
import { Terminal } from 'lucide-react'
|
|
2
|
+
import Link from 'next/link'
|
|
3
|
+
import { redirect } from 'next/navigation'
|
|
4
|
+
import {
|
|
5
|
+
Card,
|
|
6
|
+
CardContent,
|
|
7
|
+
CardDescription,
|
|
8
|
+
CardFooter,
|
|
9
|
+
CardHeader,
|
|
10
|
+
CardTitle
|
|
11
|
+
} from '~/components/ui/card'
|
|
12
|
+
import { Separator } from '~/components/ui/separator'
|
|
13
|
+
import { getAuthSession } from '~/lib/auth'
|
|
14
|
+
import { EmailLoginForm } from './email-login-form'
|
|
15
|
+
import { SocialLoginButtons } from './social-login-buttons'
|
|
16
|
+
|
|
17
|
+
function sanitizeCallback(value: string | string[] | undefined) {
|
|
18
|
+
if (typeof value !== 'string') return null
|
|
19
|
+
if (!value.startsWith('/') || value.startsWith('//')) return null
|
|
20
|
+
return value
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export default async function LoginPage({
|
|
24
|
+
searchParams
|
|
25
|
+
}: {
|
|
26
|
+
searchParams?: Promise<Record<string, string | string[] | undefined>>
|
|
27
|
+
}) {
|
|
28
|
+
const resolvedParams = (await searchParams) ?? {}
|
|
29
|
+
const callbackUrl = sanitizeCallback(resolvedParams.callbackUrl)
|
|
30
|
+
|
|
31
|
+
const authSession = await getAuthSession()
|
|
32
|
+
|
|
33
|
+
if (authSession) {
|
|
34
|
+
return redirect(callbackUrl ?? (authSession.isAdmin ? '/admin' : '/app'))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<main className="flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
|
|
39
|
+
<Card className="w-full max-w-md">
|
|
40
|
+
<CardHeader className="space-y-1 text-center">
|
|
41
|
+
<div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
|
42
|
+
<Terminal className="text-primary size-6" />
|
|
43
|
+
</div>
|
|
44
|
+
<CardTitle className="text-2xl font-bold">Welcome back</CardTitle>
|
|
45
|
+
<CardDescription className="text-muted-foreground text-sm">
|
|
46
|
+
Sign in to your account to continue
|
|
47
|
+
</CardDescription>
|
|
48
|
+
</CardHeader>
|
|
49
|
+
<CardContent className="space-y-4">
|
|
50
|
+
<div className="border-muted-foreground/50 bg-muted/50 rounded-lg border border-dashed p-4">
|
|
51
|
+
<p className="text-muted-foreground text-sm">
|
|
52
|
+
<strong>New to our site?</strong> No need to create a separate
|
|
53
|
+
account. Simply use one of the options below to both sign up and
|
|
54
|
+
log in.
|
|
55
|
+
</p>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<EmailLoginForm callbackUrl={callbackUrl} />
|
|
59
|
+
|
|
60
|
+
<div className="relative">
|
|
61
|
+
<div className="absolute inset-0 flex items-center">
|
|
62
|
+
<span className="w-full border-t" />
|
|
63
|
+
</div>
|
|
64
|
+
<div className="relative flex justify-center text-xs uppercase">
|
|
65
|
+
<span className="bg-background text-muted-foreground px-2">
|
|
66
|
+
Or continue with
|
|
67
|
+
</span>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
71
|
+
<SocialLoginButtons callbackUrl={callbackUrl} />
|
|
72
|
+
</CardContent>
|
|
73
|
+
<CardFooter className="flex flex-col space-y-4">
|
|
74
|
+
<Separator className="my-2" />
|
|
75
|
+
<div className="text-muted-foreground text-center text-sm">
|
|
76
|
+
<Link
|
|
77
|
+
href="/"
|
|
78
|
+
className="text-primary underline-offset-4 hover:underline"
|
|
79
|
+
>
|
|
80
|
+
Return to home page
|
|
81
|
+
</Link>
|
|
82
|
+
</div>
|
|
83
|
+
</CardFooter>
|
|
84
|
+
</Card>
|
|
85
|
+
</main>
|
|
86
|
+
)
|
|
87
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Button } from '~/components/ui/button'
|
|
4
|
+
import { getLastUsedLoginMethod, signIn } from '~/lib/auth-client'
|
|
5
|
+
|
|
6
|
+
export const SocialLoginButtons = ({
|
|
7
|
+
callbackUrl
|
|
8
|
+
}: {
|
|
9
|
+
callbackUrl?: string | null
|
|
10
|
+
}) => {
|
|
11
|
+
const lastUsedLoginMethod = getLastUsedLoginMethod()
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div className="flex flex-col space-y-2">
|
|
15
|
+
<Button
|
|
16
|
+
variant="outline"
|
|
17
|
+
onClick={async () => {
|
|
18
|
+
await signIn.social({
|
|
19
|
+
provider: 'google',
|
|
20
|
+
callbackURL: callbackUrl || '/login'
|
|
21
|
+
})
|
|
22
|
+
}}
|
|
23
|
+
>
|
|
24
|
+
<svg
|
|
25
|
+
viewBox="0 0 24 24"
|
|
26
|
+
width="16"
|
|
27
|
+
height="16"
|
|
28
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
29
|
+
>
|
|
30
|
+
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
|
|
31
|
+
<path
|
|
32
|
+
fill="#4285F4"
|
|
33
|
+
d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"
|
|
34
|
+
/>
|
|
35
|
+
<path
|
|
36
|
+
fill="#34A853"
|
|
37
|
+
d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"
|
|
38
|
+
/>
|
|
39
|
+
<path
|
|
40
|
+
fill="#FBBC05"
|
|
41
|
+
d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"
|
|
42
|
+
/>
|
|
43
|
+
<path
|
|
44
|
+
fill="#EA4335"
|
|
45
|
+
d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"
|
|
46
|
+
/>
|
|
47
|
+
</g>
|
|
48
|
+
</svg>
|
|
49
|
+
Continue with Google
|
|
50
|
+
{lastUsedLoginMethod === 'google' ? ' (last used)' : ''}
|
|
51
|
+
</Button>
|
|
52
|
+
|
|
53
|
+
<Button
|
|
54
|
+
variant="outline"
|
|
55
|
+
onClick={async () => {
|
|
56
|
+
await signIn.social({
|
|
57
|
+
provider: 'github',
|
|
58
|
+
callbackURL: callbackUrl || '/login'
|
|
59
|
+
})
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
<svg
|
|
63
|
+
viewBox="0 0 24 24"
|
|
64
|
+
width="16"
|
|
65
|
+
height="16"
|
|
66
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
67
|
+
>
|
|
68
|
+
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
|
69
|
+
</svg>
|
|
70
|
+
Continue with GitHub
|
|
71
|
+
{lastUsedLoginMethod === 'github' ? ' (last used)' : ''}
|
|
72
|
+
</Button>
|
|
73
|
+
</div>
|
|
74
|
+
)
|
|
75
|
+
}
|
package/template/saas-starter/src/app/(invitation)/accept-invitation/[id]/accept-invitation-form.tsx
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import { useRouter } from 'next/navigation'
|
|
5
|
+
import { useState } from 'react'
|
|
6
|
+
import { toast } from 'sonner'
|
|
7
|
+
import { Button } from '~/components/ui/button'
|
|
8
|
+
import { organization, signOut } from '~/lib/auth-client'
|
|
9
|
+
|
|
10
|
+
export function AcceptInvitationForm({
|
|
11
|
+
invitationId,
|
|
12
|
+
organizationName
|
|
13
|
+
}: {
|
|
14
|
+
invitationId: string
|
|
15
|
+
organizationName: string
|
|
16
|
+
}) {
|
|
17
|
+
const router = useRouter()
|
|
18
|
+
const [accepting, setAccepting] = useState(false)
|
|
19
|
+
const [declining, setDeclining] = useState(false)
|
|
20
|
+
|
|
21
|
+
async function handleAccept() {
|
|
22
|
+
setAccepting(true)
|
|
23
|
+
const { error } = await organization.acceptInvitation({ invitationId })
|
|
24
|
+
if (error) {
|
|
25
|
+
toast.error(error.message ?? 'Failed to accept invitation')
|
|
26
|
+
setAccepting(false)
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
toast.success(`You've joined ${organizationName}`)
|
|
30
|
+
router.push('/app')
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function handleDecline() {
|
|
34
|
+
setDeclining(true)
|
|
35
|
+
const { error } = await organization.rejectInvitation({ invitationId })
|
|
36
|
+
if (error) {
|
|
37
|
+
toast.error(error.message ?? 'Failed to decline invitation')
|
|
38
|
+
setDeclining(false)
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
toast('Invitation declined')
|
|
42
|
+
router.push('/')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const disabled = accepting || declining
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="flex flex-col gap-3">
|
|
49
|
+
<Button onClick={handleAccept} disabled={disabled} className="w-full">
|
|
50
|
+
{accepting ? 'Accepting...' : 'Accept invitation'}
|
|
51
|
+
</Button>
|
|
52
|
+
<Button
|
|
53
|
+
onClick={handleDecline}
|
|
54
|
+
disabled={disabled}
|
|
55
|
+
variant="outline"
|
|
56
|
+
className="w-full"
|
|
57
|
+
>
|
|
58
|
+
{declining ? 'Declining...' : 'Decline'}
|
|
59
|
+
</Button>
|
|
60
|
+
</div>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function WrongAccountActions({
|
|
65
|
+
invitationId
|
|
66
|
+
}: {
|
|
67
|
+
invitationId: string
|
|
68
|
+
}) {
|
|
69
|
+
const router = useRouter()
|
|
70
|
+
const [loading, setLoading] = useState(false)
|
|
71
|
+
|
|
72
|
+
async function handleSwitch() {
|
|
73
|
+
setLoading(true)
|
|
74
|
+
await signOut()
|
|
75
|
+
router.push(`/login?callbackUrl=/accept-invitation/${invitationId}`)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="flex justify-center gap-3">
|
|
80
|
+
<Button asChild variant="outline">
|
|
81
|
+
<Link href="/">Return home</Link>
|
|
82
|
+
</Button>
|
|
83
|
+
<Button onClick={handleSwitch} disabled={loading}>
|
|
84
|
+
{loading ? 'Signing out...' : 'Switch account'}
|
|
85
|
+
</Button>
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm'
|
|
2
|
+
import { Building2 } from 'lucide-react'
|
|
3
|
+
import Link from 'next/link'
|
|
4
|
+
import { Button } from '~/components/ui/button'
|
|
5
|
+
import {
|
|
6
|
+
Card,
|
|
7
|
+
CardContent,
|
|
8
|
+
CardDescription,
|
|
9
|
+
CardFooter,
|
|
10
|
+
CardHeader,
|
|
11
|
+
CardTitle
|
|
12
|
+
} from '~/components/ui/card'
|
|
13
|
+
import { db } from '~/db'
|
|
14
|
+
import { invitationsTable } from '~/db/schema'
|
|
15
|
+
import { getAuthSession } from '~/lib/auth'
|
|
16
|
+
import {
|
|
17
|
+
AcceptInvitationForm,
|
|
18
|
+
WrongAccountActions
|
|
19
|
+
} from './accept-invitation-form'
|
|
20
|
+
|
|
21
|
+
export default async function AcceptInvitationPage({
|
|
22
|
+
params
|
|
23
|
+
}: {
|
|
24
|
+
params: Promise<{ id: string }>
|
|
25
|
+
}) {
|
|
26
|
+
const { id } = await params
|
|
27
|
+
|
|
28
|
+
const invitation = await db.query.invitationsTable.findFirst({
|
|
29
|
+
where: eq(invitationsTable.id, id),
|
|
30
|
+
with: {
|
|
31
|
+
organization: {
|
|
32
|
+
columns: {
|
|
33
|
+
id: true,
|
|
34
|
+
name: true
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
user: {
|
|
38
|
+
columns: {
|
|
39
|
+
id: true,
|
|
40
|
+
name: true
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
columns: {
|
|
45
|
+
id: true,
|
|
46
|
+
status: true,
|
|
47
|
+
expiresAt: true,
|
|
48
|
+
email: true,
|
|
49
|
+
role: true
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
if (!invitation || invitation.status !== 'pending') {
|
|
54
|
+
return (
|
|
55
|
+
<main className="flex min-h-screen items-center justify-center px-4 py-12">
|
|
56
|
+
<Card className="w-full max-w-md text-center">
|
|
57
|
+
<CardHeader>
|
|
58
|
+
<CardTitle>Invitation not found</CardTitle>
|
|
59
|
+
<CardDescription>
|
|
60
|
+
This invitation link is invalid or has already been used.
|
|
61
|
+
</CardDescription>
|
|
62
|
+
</CardHeader>
|
|
63
|
+
<CardFooter className="justify-center">
|
|
64
|
+
<Button asChild variant="outline">
|
|
65
|
+
<Link href="/">Return home</Link>
|
|
66
|
+
</Button>
|
|
67
|
+
</CardFooter>
|
|
68
|
+
</Card>
|
|
69
|
+
</main>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (invitation.expiresAt && invitation.expiresAt < new Date()) {
|
|
74
|
+
return (
|
|
75
|
+
<main className="flex min-h-screen items-center justify-center px-4 py-12">
|
|
76
|
+
<Card className="w-full max-w-md text-center">
|
|
77
|
+
<CardHeader>
|
|
78
|
+
<CardTitle>Invitation expired</CardTitle>
|
|
79
|
+
<CardDescription>
|
|
80
|
+
This invitation to <strong>{invitation.organization.name}</strong>{' '}
|
|
81
|
+
has expired. Ask your organization admin for a new one.
|
|
82
|
+
</CardDescription>
|
|
83
|
+
</CardHeader>
|
|
84
|
+
<CardFooter className="justify-center">
|
|
85
|
+
<Button asChild variant="outline">
|
|
86
|
+
<Link href="/">Return home</Link>
|
|
87
|
+
</Button>
|
|
88
|
+
</CardFooter>
|
|
89
|
+
</Card>
|
|
90
|
+
</main>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const authSession = await getAuthSession()
|
|
95
|
+
|
|
96
|
+
if (!authSession) {
|
|
97
|
+
return (
|
|
98
|
+
<main className="flex min-h-screen items-center justify-center px-4 py-12">
|
|
99
|
+
<Card className="w-full max-w-md">
|
|
100
|
+
<CardHeader className="text-center">
|
|
101
|
+
<div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
|
102
|
+
<Building2 className="text-primary size-6" />
|
|
103
|
+
</div>
|
|
104
|
+
<CardTitle>You've been invited</CardTitle>
|
|
105
|
+
<CardDescription>
|
|
106
|
+
<strong>{invitation.user.name}</strong> invited you to join{' '}
|
|
107
|
+
<strong>{invitation.organization.name}</strong>. Sign in to
|
|
108
|
+
accept.
|
|
109
|
+
</CardDescription>
|
|
110
|
+
</CardHeader>
|
|
111
|
+
<CardContent>
|
|
112
|
+
<Button asChild className="w-full">
|
|
113
|
+
<Link href={`/login?callbackUrl=/accept-invitation/${id}`}>
|
|
114
|
+
Sign in to accept
|
|
115
|
+
</Link>
|
|
116
|
+
</Button>
|
|
117
|
+
</CardContent>
|
|
118
|
+
</Card>
|
|
119
|
+
</main>
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (authSession.user.email.toLowerCase() !== invitation.email.toLowerCase()) {
|
|
124
|
+
return (
|
|
125
|
+
<main className="flex min-h-screen items-center justify-center px-4 py-12">
|
|
126
|
+
<Card className="w-full max-w-md text-center">
|
|
127
|
+
<CardHeader>
|
|
128
|
+
<CardTitle>Wrong account</CardTitle>
|
|
129
|
+
<CardDescription>
|
|
130
|
+
This invitation was sent to <strong>{invitation.email}</strong>{' '}
|
|
131
|
+
but you're signed in as{' '}
|
|
132
|
+
<strong>{authSession.user.email}</strong>. Sign in with the
|
|
133
|
+
correct account to accept.
|
|
134
|
+
</CardDescription>
|
|
135
|
+
</CardHeader>
|
|
136
|
+
<CardFooter className="justify-center">
|
|
137
|
+
<WrongAccountActions invitationId={id} />
|
|
138
|
+
</CardFooter>
|
|
139
|
+
</Card>
|
|
140
|
+
</main>
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<main className="flex min-h-screen items-center justify-center px-4 py-12">
|
|
146
|
+
<Card className="w-full max-w-md">
|
|
147
|
+
<CardHeader className="text-center">
|
|
148
|
+
<div className="bg-primary/10 mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full">
|
|
149
|
+
<Building2 className="text-primary size-6" />
|
|
150
|
+
</div>
|
|
151
|
+
<CardTitle>Join {invitation.organization.name}</CardTitle>
|
|
152
|
+
<CardDescription>
|
|
153
|
+
<strong>{invitation.user.name}</strong> has invited you to join{' '}
|
|
154
|
+
<strong>{invitation.organization.name}</strong>
|
|
155
|
+
{invitation.role ? ` as ${invitation.role}` : ''}.
|
|
156
|
+
</CardDescription>
|
|
157
|
+
</CardHeader>
|
|
158
|
+
<CardContent>
|
|
159
|
+
<AcceptInvitationForm
|
|
160
|
+
invitationId={id}
|
|
161
|
+
organizationName={invitation.organization.name}
|
|
162
|
+
/>
|
|
163
|
+
</CardContent>
|
|
164
|
+
</Card>
|
|
165
|
+
</main>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { SiteFooter } from '~/components/site-footer'
|
|
2
|
+
|
|
3
|
+
export default async function Layout({
|
|
4
|
+
children
|
|
5
|
+
}: Readonly<{
|
|
6
|
+
children: React.ReactNode
|
|
7
|
+
}>) {
|
|
8
|
+
return (
|
|
9
|
+
<div className="flex min-h-screen flex-col space-y-4">
|
|
10
|
+
<main className="flex-1">{children}</main>
|
|
11
|
+
<SiteFooter />
|
|
12
|
+
</div>
|
|
13
|
+
)
|
|
14
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { eq } from 'drizzle-orm'
|
|
2
|
+
import { type NextRequest, NextResponse } from 'next/server'
|
|
3
|
+
import type Stripe from 'stripe'
|
|
4
|
+
import { db } from '~/db'
|
|
5
|
+
import { stripeWebhooksEventsTable } from '~/db/schema'
|
|
6
|
+
import { env } from '~/env'
|
|
7
|
+
import { syncSubscriptionFromStripe } from '~/lib/billing'
|
|
8
|
+
import { stripeClient } from '~/lib/stripe'
|
|
9
|
+
|
|
10
|
+
const RELEVANT_EVENTS: ReadonlySet<string> = new Set([
|
|
11
|
+
'checkout.session.completed',
|
|
12
|
+
'customer.subscription.created',
|
|
13
|
+
'customer.subscription.updated',
|
|
14
|
+
'customer.subscription.deleted',
|
|
15
|
+
'customer.subscription.trial_will_end',
|
|
16
|
+
'customer.subscription.paused',
|
|
17
|
+
'customer.subscription.resumed',
|
|
18
|
+
'invoice.paid',
|
|
19
|
+
'invoice.payment_succeeded',
|
|
20
|
+
'invoice.payment_failed'
|
|
21
|
+
])
|
|
22
|
+
|
|
23
|
+
const extractCustomerId = (event: Stripe.Event): string | null => {
|
|
24
|
+
const object = event.data.object as {
|
|
25
|
+
customer?: string | { id: string } | null
|
|
26
|
+
}
|
|
27
|
+
const customer = object.customer
|
|
28
|
+
if (!customer) return null
|
|
29
|
+
return typeof customer === 'string' ? customer : customer.id
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function POST(req: NextRequest) {
|
|
33
|
+
const body = await req.text()
|
|
34
|
+
const signature = req.headers.get('stripe-signature')
|
|
35
|
+
|
|
36
|
+
if (!signature) {
|
|
37
|
+
return new NextResponse('Missing stripe-signature header', { status: 400 })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let event: Stripe.Event
|
|
41
|
+
try {
|
|
42
|
+
event = stripeClient.webhooks.constructEvent(
|
|
43
|
+
body,
|
|
44
|
+
signature,
|
|
45
|
+
env.STRIPE_WEBHOOK_SECRET
|
|
46
|
+
)
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error('Stripe webhook signature verification failed:', error)
|
|
49
|
+
return new NextResponse('Unauthorized', { status: 400 })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!RELEVANT_EVENTS.has(event.type)) {
|
|
53
|
+
return NextResponse.json({ received: true, ignored: true })
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const existing = await db.query.stripeWebhooksEventsTable.findFirst({
|
|
57
|
+
where: eq(stripeWebhooksEventsTable.id, event.id)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
if (existing?.processed) {
|
|
61
|
+
return NextResponse.json({ received: true, duplicate: true })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!existing) {
|
|
65
|
+
await db
|
|
66
|
+
.insert(stripeWebhooksEventsTable)
|
|
67
|
+
.values({ id: event.id, type: event.type })
|
|
68
|
+
.onConflictDoNothing()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const customerId = extractCustomerId(event)
|
|
73
|
+
if (customerId) {
|
|
74
|
+
await syncSubscriptionFromStripe(customerId)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
await db
|
|
78
|
+
.update(stripeWebhooksEventsTable)
|
|
79
|
+
.set({ processed: true, processedAt: new Date(), error: null })
|
|
80
|
+
.where(eq(stripeWebhooksEventsTable.id, event.id))
|
|
81
|
+
|
|
82
|
+
return NextResponse.json({ received: true, processed: true })
|
|
83
|
+
} catch (error) {
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
85
|
+
console.error(`Failed to process Stripe event ${event.id}:`, error)
|
|
86
|
+
|
|
87
|
+
await db
|
|
88
|
+
.update(stripeWebhooksEventsTable)
|
|
89
|
+
.set({ error: message, payload: event as unknown })
|
|
90
|
+
.where(eq(stripeWebhooksEventsTable.id, event.id))
|
|
91
|
+
|
|
92
|
+
return new NextResponse('Webhook handler failed', { status: 500 })
|
|
93
|
+
}
|
|
94
|
+
}
|
|
Binary file
|