create-skit 0.1.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/README.md +36 -0
- package/bin/create-skit.mjs +1064 -0
- package/lib/module-application.mjs +281 -0
- package/lib/module-resolver.mjs +179 -0
- package/modules/README.md +22 -0
- package/modules/ai-dx/files/AGENTS.md +116 -0
- package/modules/ai-dx/files/ARCHITECTURE.md +103 -0
- package/modules/ai-dx/module.json +14 -0
- package/modules/ai-dx-claude/files/CLAUDE.md +8 -0
- package/modules/ai-dx-claude/module.json +13 -0
- package/modules/ai-dx-cursor/files/.cursor/rules/auth.mdc +53 -0
- package/modules/ai-dx-cursor/files/.cursor/rules/database.mdc +48 -0
- package/modules/ai-dx-cursor/files/.cursor/rules/env.mdc +43 -0
- package/modules/ai-dx-cursor/files/.cursor/rules/nextjs.mdc +58 -0
- package/modules/ai-dx-cursor/files/.cursor/rules/project.mdc +33 -0
- package/modules/ai-dx-cursor/files/.cursor/rules/testing.mdc +55 -0
- package/modules/ai-dx-cursor/module.json +18 -0
- package/modules/ai-dx-gemini/files/.gemini/GEMINI.md +5 -0
- package/modules/ai-dx-gemini/module.json +13 -0
- package/modules/auth-core/module.json +8 -0
- package/modules/auth-github/module.json +20 -0
- package/modules/billing-polar/module.json +20 -0
- package/modules/billing-stripe/module.json +23 -0
- package/modules/dashboard-shell/files/src/app/globals.css +756 -0
- package/modules/dashboard-shell/files/src/app/settings/page.tsx +67 -0
- package/modules/dashboard-shell/module.json +11 -0
- package/modules/db-pg/module.json +21 -0
- package/modules/db-postgresjs/module.json +21 -0
- package/modules/deploy-docker/files/.dockerignore +19 -0
- package/modules/deploy-docker/files/Dockerfile +25 -0
- package/modules/deploy-docker/module.json +11 -0
- package/modules/email-resend/module.json +21 -0
- package/modules/quality-baseline/module.json +8 -0
- package/modules/testing-baseline/module.json +8 -0
- package/package.json +40 -0
- package/presets/README.md +12 -0
- package/presets/blank.json +67 -0
- package/presets/dashboard.json +67 -0
- package/templates/base-web/.env.example +17 -0
- package/templates/base-web/.github/workflows/ci.yml +34 -0
- package/templates/base-web/.husky/pre-commit +3 -0
- package/templates/base-web/.husky/pre-push +3 -0
- package/templates/base-web/.prettierignore +3 -0
- package/templates/base-web/README.md +42 -0
- package/templates/base-web/drizzle.config.ts +16 -0
- package/templates/base-web/eslint.config.mjs +127 -0
- package/templates/base-web/manifest.json +5 -0
- package/templates/base-web/next-env.d.ts +4 -0
- package/templates/base-web/next.config.ts +5 -0
- package/templates/base-web/package.json +75 -0
- package/templates/base-web/playwright.config.ts +21 -0
- package/templates/base-web/prettier.config.mjs +9 -0
- package/templates/base-web/proxy.ts +23 -0
- package/templates/base-web/src/app/api/auth/[...all]/route.ts +5 -0
- package/templates/base-web/src/app/api/billing/checkout/route.ts +26 -0
- package/templates/base-web/src/app/api/billing/portal/route.ts +25 -0
- package/templates/base-web/src/app/api/email/test/route.ts +28 -0
- package/templates/base-web/src/app/api/webhooks/polar/route.ts +5 -0
- package/templates/base-web/src/app/api/webhooks/stripe/route.ts +5 -0
- package/templates/base-web/src/app/billing/page.tsx +55 -0
- package/templates/base-web/src/app/dashboard/page.tsx +15 -0
- package/templates/base-web/src/app/email/page.tsx +46 -0
- package/templates/base-web/src/app/error.tsx +27 -0
- package/templates/base-web/src/app/globals.css +534 -0
- package/templates/base-web/src/app/layout.tsx +19 -0
- package/templates/base-web/src/app/llms-full.txt/route.ts +158 -0
- package/templates/base-web/src/app/llms.txt/route.ts +59 -0
- package/templates/base-web/src/app/loading.tsx +24 -0
- package/templates/base-web/src/app/not-found.tsx +16 -0
- package/templates/base-web/src/app/page.tsx +5 -0
- package/templates/base-web/src/app/sign-in/page.tsx +14 -0
- package/templates/base-web/src/app/sign-up/page.tsx +14 -0
- package/templates/base-web/src/components/auth/email-auth-form.test.tsx +40 -0
- package/templates/base-web/src/components/auth/email-auth-form.tsx +128 -0
- package/templates/base-web/src/components/auth/sign-out-button.tsx +29 -0
- package/templates/base-web/src/db/index.ts +16 -0
- package/templates/base-web/src/db/schema/auth.ts +4 -0
- package/templates/base-web/src/db/schema/index.ts +2 -0
- package/templates/base-web/src/db/schema/projects.ts +17 -0
- package/templates/base-web/src/db/seeds/index.ts +32 -0
- package/templates/base-web/src/lib/auth-client.ts +5 -0
- package/templates/base-web/src/lib/auth-session.ts +21 -0
- package/templates/base-web/src/lib/auth.ts +23 -0
- package/templates/base-web/src/lib/billing/index.ts +37 -0
- package/templates/base-web/src/lib/billing/providers/polar.ts +80 -0
- package/templates/base-web/src/lib/billing/providers/stripe.ts +77 -0
- package/templates/base-web/src/lib/billing/types.ts +25 -0
- package/templates/base-web/src/lib/email/index.ts +19 -0
- package/templates/base-web/src/lib/email/templates.test.ts +12 -0
- package/templates/base-web/src/lib/email/templates.ts +40 -0
- package/templates/base-web/src/lib/env.ts +83 -0
- package/templates/base-web/tests/e2e/home.spec.ts +8 -0
- package/templates/base-web/tsconfig.json +34 -0
- package/templates/base-web/vitest.config.ts +19 -0
- package/templates/blank/.env.example +16 -0
- package/templates/blank/.github/workflows/ci.yml +34 -0
- package/templates/blank/.husky/pre-commit +3 -0
- package/templates/blank/.husky/pre-push +3 -0
- package/templates/blank/.prettierignore +3 -0
- package/templates/blank/drizzle.config.ts +16 -0
- package/templates/blank/eslint.config.mjs +127 -0
- package/templates/blank/next-env.d.ts +4 -0
- package/templates/blank/next.config.ts +5 -0
- package/templates/blank/package.json +75 -0
- package/templates/blank/playwright.config.ts +21 -0
- package/templates/blank/prettier.config.mjs +9 -0
- package/templates/blank/proxy.ts +28 -0
- package/templates/blank/src/app/api/auth/[...all]/route.ts +5 -0
- package/templates/blank/src/app/api/billing/checkout/route.ts +26 -0
- package/templates/blank/src/app/api/billing/portal/route.ts +25 -0
- package/templates/blank/src/app/api/email/test/route.ts +28 -0
- package/templates/blank/src/app/api/webhooks/polar/route.ts +5 -0
- package/templates/blank/src/app/api/webhooks/stripe/route.ts +5 -0
- package/templates/blank/src/app/billing/page.tsx +70 -0
- package/templates/blank/src/app/email/page.tsx +46 -0
- package/templates/blank/src/app/globals.css +394 -0
- package/templates/blank/src/app/layout.tsx +19 -0
- package/templates/blank/src/app/page.tsx +23 -0
- package/templates/blank/src/app/sign-in/page.tsx +18 -0
- package/templates/blank/src/app/sign-up/page.tsx +18 -0
- package/templates/blank/src/components/auth/email-auth-form.test.tsx +39 -0
- package/templates/blank/src/components/auth/email-auth-form.tsx +109 -0
- package/templates/blank/src/components/auth/sign-out-button.tsx +29 -0
- package/templates/blank/src/db/index.ts +16 -0
- package/templates/blank/src/db/schema/auth.ts +4 -0
- package/templates/blank/src/db/schema/index.ts +2 -0
- package/templates/blank/src/db/schema/projects.ts +17 -0
- package/templates/blank/src/db/seeds/index.ts +28 -0
- package/templates/blank/src/lib/auth-client.ts +5 -0
- package/templates/blank/src/lib/auth-session.ts +11 -0
- package/templates/blank/src/lib/auth.ts +23 -0
- package/templates/blank/src/lib/billing/index.ts +37 -0
- package/templates/blank/src/lib/billing/providers/polar.ts +80 -0
- package/templates/blank/src/lib/billing/providers/stripe.ts +77 -0
- package/templates/blank/src/lib/billing/types.ts +25 -0
- package/templates/blank/src/lib/email/index.ts +19 -0
- package/templates/blank/src/lib/email/templates.test.ts +15 -0
- package/templates/blank/src/lib/email/templates.ts +40 -0
- package/templates/blank/src/lib/env.ts +80 -0
- package/templates/blank/tsconfig.json +34 -0
- package/templates/blank/vitest.config.ts +19 -0
- package/templates/dashboard/.env.example +16 -0
- package/templates/dashboard/.github/workflows/ci.yml +34 -0
- package/templates/dashboard/.husky/pre-commit +3 -0
- package/templates/dashboard/.husky/pre-push +3 -0
- package/templates/dashboard/.prettierignore +3 -0
- package/templates/dashboard/drizzle.config.ts +16 -0
- package/templates/dashboard/eslint.config.mjs +127 -0
- package/templates/dashboard/next-env.d.ts +4 -0
- package/templates/dashboard/next.config.ts +5 -0
- package/templates/dashboard/package.json +75 -0
- package/templates/dashboard/playwright.config.ts +21 -0
- package/templates/dashboard/prettier.config.mjs +9 -0
- package/templates/dashboard/proxy.ts +36 -0
- package/templates/dashboard/src/app/api/auth/[...all]/route.ts +5 -0
- package/templates/dashboard/src/app/api/billing/checkout/route.ts +26 -0
- package/templates/dashboard/src/app/api/billing/portal/route.ts +25 -0
- package/templates/dashboard/src/app/api/email/test/route.ts +28 -0
- package/templates/dashboard/src/app/api/webhooks/polar/route.ts +5 -0
- package/templates/dashboard/src/app/api/webhooks/stripe/route.ts +5 -0
- package/templates/dashboard/src/app/billing/layout.tsx +22 -0
- package/templates/dashboard/src/app/billing/page.tsx +73 -0
- package/templates/dashboard/src/app/dashboard/layout.tsx +22 -0
- package/templates/dashboard/src/app/dashboard/page.tsx +104 -0
- package/templates/dashboard/src/app/email/layout.tsx +22 -0
- package/templates/dashboard/src/app/email/page.tsx +54 -0
- package/templates/dashboard/src/app/globals.css +1357 -0
- package/templates/dashboard/src/app/layout.tsx +25 -0
- package/templates/dashboard/src/app/page.tsx +154 -0
- package/templates/dashboard/src/app/settings/layout.tsx +22 -0
- package/templates/dashboard/src/app/settings/page.tsx +85 -0
- package/templates/dashboard/src/app/sign-in/page.tsx +47 -0
- package/templates/dashboard/src/app/sign-up/page.tsx +47 -0
- package/templates/dashboard/src/components/auth/email-auth-form.test.tsx +39 -0
- package/templates/dashboard/src/components/auth/email-auth-form.tsx +160 -0
- package/templates/dashboard/src/components/auth/sign-out-button.tsx +29 -0
- package/templates/dashboard/src/components/dashboard/shell.tsx +158 -0
- package/templates/dashboard/src/db/index.ts +16 -0
- package/templates/dashboard/src/db/schema/auth.ts +4 -0
- package/templates/dashboard/src/db/schema/index.ts +2 -0
- package/templates/dashboard/src/db/schema/projects.ts +17 -0
- package/templates/dashboard/src/db/seeds/index.ts +28 -0
- package/templates/dashboard/src/lib/auth-client.ts +5 -0
- package/templates/dashboard/src/lib/auth-session.ts +11 -0
- package/templates/dashboard/src/lib/auth.ts +41 -0
- package/templates/dashboard/src/lib/billing/index.ts +37 -0
- package/templates/dashboard/src/lib/billing/providers/polar.ts +80 -0
- package/templates/dashboard/src/lib/billing/providers/stripe.ts +77 -0
- package/templates/dashboard/src/lib/billing/types.ts +25 -0
- package/templates/dashboard/src/lib/email/index.ts +19 -0
- package/templates/dashboard/src/lib/email/templates.test.ts +15 -0
- package/templates/dashboard/src/lib/email/templates.ts +40 -0
- package/templates/dashboard/src/lib/env.ts +88 -0
- package/templates/dashboard/tsconfig.json +34 -0
- package/templates/dashboard/vitest.config.ts +19 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export function GET() {
|
|
2
|
+
const content = `# __PROJECT_NAME__
|
|
3
|
+
|
|
4
|
+
> Built with Skit — opinionated Next.js 16 SaaS starter.
|
|
5
|
+
|
|
6
|
+
## Tech Stack
|
|
7
|
+
|
|
8
|
+
- Next.js 16 (App Router, React 19, TypeScript 5.9)
|
|
9
|
+
- Drizzle ORM + PostgreSQL
|
|
10
|
+
- Better Auth (email/password__AUTH_LLMS_EXTRA__)
|
|
11
|
+
- Tailwind CSS 4
|
|
12
|
+
- __BILLING_LLMS_LINE__
|
|
13
|
+
- __EMAIL_LLMS_LINE__
|
|
14
|
+
- Vitest + Playwright testing
|
|
15
|
+
- ESLint 9, Prettier
|
|
16
|
+
|
|
17
|
+
## Structure
|
|
18
|
+
|
|
19
|
+
- src/app/ — Next.js pages (App Router)
|
|
20
|
+
- src/lib/ — shared utilities (auth, db, billing, email, env)
|
|
21
|
+
- src/db/ — Drizzle schema and seeds
|
|
22
|
+
- AGENTS.md — AI agent rules
|
|
23
|
+
- ARCHITECTURE.md — project architecture
|
|
24
|
+
|
|
25
|
+
## Key Files
|
|
26
|
+
|
|
27
|
+
| File | Purpose |
|
|
28
|
+
|---|---|
|
|
29
|
+
| src/lib/auth.ts | Better Auth server config |
|
|
30
|
+
| src/lib/db.ts | Drizzle client |
|
|
31
|
+
| src/lib/billing.ts | Billing provider |
|
|
32
|
+
| src/lib/email.ts | Email client |
|
|
33
|
+
| src/lib/env.ts | Environment validation |
|
|
34
|
+
| src/middleware.ts | Route protection |
|
|
35
|
+
| drizzle.config.ts | Drizzle Kit config |
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
| Command | Description |
|
|
40
|
+
|---|---|
|
|
41
|
+
| pnpm dev | Start dev server |
|
|
42
|
+
| pnpm build | Production build |
|
|
43
|
+
| pnpm lint | Lint with ESLint |
|
|
44
|
+
| pnpm typecheck | TypeScript check |
|
|
45
|
+
| pnpm test | Unit tests (Vitest) |
|
|
46
|
+
| pnpm test:e2e | E2E tests (Playwright) |
|
|
47
|
+
| pnpm db:push | Push schema to database |
|
|
48
|
+
| pnpm db:seed | Seed demo data |
|
|
49
|
+
| pnpm db:studio | Open Drizzle Studio |
|
|
50
|
+
|
|
51
|
+
## Full Documentation
|
|
52
|
+
|
|
53
|
+
See /llms-full.txt for the complete project context.
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
return new Response(content, {
|
|
57
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export default function Loading() {
|
|
2
|
+
return (
|
|
3
|
+
<main
|
|
4
|
+
style={{
|
|
5
|
+
display: "grid",
|
|
6
|
+
placeItems: "center",
|
|
7
|
+
minHeight: "100dvh",
|
|
8
|
+
background: "var(--bg, #09090b)"
|
|
9
|
+
}}
|
|
10
|
+
>
|
|
11
|
+
<div
|
|
12
|
+
style={{
|
|
13
|
+
width: 32,
|
|
14
|
+
height: 32,
|
|
15
|
+
border: "3px solid var(--border, rgba(255,255,255,0.06))",
|
|
16
|
+
borderTopColor: "var(--accent, #00d4aa)",
|
|
17
|
+
borderRadius: "50%",
|
|
18
|
+
animation: "spin 0.6s linear infinite"
|
|
19
|
+
}}
|
|
20
|
+
/>
|
|
21
|
+
<style>{`@keyframes spin { to { transform: rotate(360deg) } }`}</style>
|
|
22
|
+
</main>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import Link from "next/link";
|
|
2
|
+
|
|
3
|
+
export default function NotFound() {
|
|
4
|
+
return (
|
|
5
|
+
<main className="shell">
|
|
6
|
+
<section className="hero">
|
|
7
|
+
<p className="eyebrow">404</p>
|
|
8
|
+
<h1>Page not found</h1>
|
|
9
|
+
<p className="lede">The page you are looking for does not exist or has been moved.</p>
|
|
10
|
+
<div className="hero-actions">
|
|
11
|
+
<Link href="/">Go home</Link>
|
|
12
|
+
</div>
|
|
13
|
+
</section>
|
|
14
|
+
</main>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { redirect } from "next/navigation";
|
|
2
|
+
|
|
3
|
+
import { EmailAuthForm } from "@/components/auth/email-auth-form";
|
|
4
|
+
import { getSession } from "@/lib/auth-session";
|
|
5
|
+
|
|
6
|
+
export default async function SignInPage() {
|
|
7
|
+
const session = await getSession();
|
|
8
|
+
|
|
9
|
+
if (session) {
|
|
10
|
+
redirect("/dashboard");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return <EmailAuthForm mode="sign-in" />;
|
|
14
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { redirect } from "next/navigation";
|
|
2
|
+
|
|
3
|
+
import { EmailAuthForm } from "@/components/auth/email-auth-form";
|
|
4
|
+
import { getSession } from "@/lib/auth-session";
|
|
5
|
+
|
|
6
|
+
export default async function SignUpPage() {
|
|
7
|
+
const session = await getSession();
|
|
8
|
+
|
|
9
|
+
if (session) {
|
|
10
|
+
redirect("/dashboard");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
return <EmailAuthForm mode="sign-up" />;
|
|
14
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { EmailAuthForm } from "@/components/auth/email-auth-form";
|
|
5
|
+
|
|
6
|
+
import type { ReactNode } from "react";
|
|
7
|
+
|
|
8
|
+
vi.mock("next/link", () => ({
|
|
9
|
+
default: ({ children, href }: { children: ReactNode; href: string }) => (
|
|
10
|
+
<a href={href}>{children}</a>
|
|
11
|
+
)
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
vi.mock("next/navigation", () => ({
|
|
15
|
+
useRouter: () => ({
|
|
16
|
+
push: vi.fn(),
|
|
17
|
+
refresh: vi.fn()
|
|
18
|
+
})
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
vi.mock("@/lib/auth-client", () => ({
|
|
22
|
+
authClient: {
|
|
23
|
+
signIn: {
|
|
24
|
+
email: vi.fn()
|
|
25
|
+
},
|
|
26
|
+
signUp: {
|
|
27
|
+
email: vi.fn()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
describe("EmailAuthForm", () => {
|
|
33
|
+
it("renders preset-specific auth form copy", () => {
|
|
34
|
+
render(<EmailAuthForm mode="__AUTH_FORM_TEST_MODE__" />);
|
|
35
|
+
|
|
36
|
+
expect(screen.getByRole("heading", { name: /__AUTH_FORM_TEST_HEADING__/i })).toBeTruthy();
|
|
37
|
+
expect(screen.getByLabelText(/__AUTH_FORM_TEST_LABEL__/i)).toBeTruthy();
|
|
38
|
+
__AUTH_SOCIAL_TEST_ASSERTION__
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useRouter } from "next/navigation";
|
|
5
|
+
import { useState } from "react";
|
|
6
|
+
|
|
7
|
+
import { authClient } from "@/lib/auth-client";
|
|
8
|
+
|
|
9
|
+
type EmailAuthFormProps = {
|
|
10
|
+
mode: "sign-in" | "sign-up";
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function EmailAuthForm({ mode }: EmailAuthFormProps) {
|
|
14
|
+
const isSignUp = mode === "sign-up";
|
|
15
|
+
const router = useRouter();
|
|
16
|
+
const [error, setError] = useState<string | null>(null);
|
|
17
|
+
const [isPending, setIsPending] = useState(false);
|
|
18
|
+
const [showPassword, setShowPassword] = useState(false);
|
|
19
|
+
__AUTH_SOCIAL_BUTTON_HANDLER__
|
|
20
|
+
|
|
21
|
+
async function handleSubmit(formData: FormData) {
|
|
22
|
+
const email = String(formData.get("email") ?? "");
|
|
23
|
+
const password = String(formData.get("password") ?? "");
|
|
24
|
+
const name = String(formData.get("name") ?? "");
|
|
25
|
+
|
|
26
|
+
setIsPending(true);
|
|
27
|
+
setError(null);
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const result = isSignUp
|
|
31
|
+
? await authClient.signUp.email({ email, password, name })
|
|
32
|
+
: await authClient.signIn.email({ email, password });
|
|
33
|
+
|
|
34
|
+
if (result.error) {
|
|
35
|
+
setError(result.error.message ?? "Authentication failed.");
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
router.push("/dashboard");
|
|
40
|
+
router.refresh();
|
|
41
|
+
} catch {
|
|
42
|
+
setError("Authentication failed.");
|
|
43
|
+
} finally {
|
|
44
|
+
setIsPending(false);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="auth-container">
|
|
50
|
+
<div className="auth-card">
|
|
51
|
+
<div className="auth-brand">__PROJECT_NAME__</div>
|
|
52
|
+
|
|
53
|
+
<div className="auth-header">
|
|
54
|
+
<h1>{isSignUp ? "__AUTH_FORM_SIGN_UP_HEADING__" : "__AUTH_FORM_SIGN_IN_HEADING__"}</h1>
|
|
55
|
+
<p>{isSignUp ? "__AUTH_FORM_SIGN_UP_LEDE__" : "__AUTH_FORM_SIGN_IN_LEDE__"}</p>
|
|
56
|
+
</div>
|
|
57
|
+
__AUTH_SOCIAL_BUTTON__
|
|
58
|
+
|
|
59
|
+
<div className="auth-tabs">
|
|
60
|
+
<Link href="/sign-in" className={`auth-tab ${!isSignUp ? "active" : ""}`}>
|
|
61
|
+
Sign in
|
|
62
|
+
</Link>
|
|
63
|
+
<Link href="/sign-up" className={`auth-tab ${isSignUp ? "active" : ""}`}>
|
|
64
|
+
Sign up
|
|
65
|
+
</Link>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<form
|
|
69
|
+
action={(formData) => { void handleSubmit(formData); }}
|
|
70
|
+
className="auth-form"
|
|
71
|
+
>
|
|
72
|
+
{isSignUp ? (
|
|
73
|
+
<label className="auth-field">
|
|
74
|
+
<span className="auth-label">NAME</span>
|
|
75
|
+
<div className="auth-input-wrap">
|
|
76
|
+
<svg className="auth-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5"><circle cx="10" cy="7" r="3.5" /><path d="M3.5 17.5c0-3.5 2.9-6 6.5-6s6.5 2.5 6.5 6" /></svg>
|
|
77
|
+
<input name="name" type="text" placeholder="Ada Lovelace" required />
|
|
78
|
+
</div>
|
|
79
|
+
</label>
|
|
80
|
+
) : null}
|
|
81
|
+
|
|
82
|
+
<label className="auth-field">
|
|
83
|
+
<span className="auth-label">EMAIL</span>
|
|
84
|
+
<div className="auth-input-wrap">
|
|
85
|
+
<svg className="auth-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="2" y="4" width="16" height="12" rx="2" /><path d="M2 6l8 5 8-5" /></svg>
|
|
86
|
+
<input name="email" type="email" placeholder="you@example.com" required />
|
|
87
|
+
</div>
|
|
88
|
+
</label>
|
|
89
|
+
|
|
90
|
+
<label className="auth-field">
|
|
91
|
+
<span className="auth-label">PASSWORD</span>
|
|
92
|
+
<div className="auth-input-wrap">
|
|
93
|
+
<svg className="auth-icon" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="4" y="9" width="12" height="8" rx="2" /><path d="M7 9V6a3 3 0 016 0v3" /></svg>
|
|
94
|
+
<input
|
|
95
|
+
name="password"
|
|
96
|
+
type={showPassword ? "text" : "password"}
|
|
97
|
+
placeholder="At least 8 characters"
|
|
98
|
+
minLength={8}
|
|
99
|
+
required
|
|
100
|
+
/>
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
className="auth-toggle-pw"
|
|
104
|
+
onClick={() => setShowPassword(!showPassword)}
|
|
105
|
+
tabIndex={-1}
|
|
106
|
+
>
|
|
107
|
+
{showPassword ? (
|
|
108
|
+
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 10s3-5 7-5 7 5 7 5-3 5-7 5-7-5-7-5z" /><circle cx="10" cy="10" r="2.5" /></svg>
|
|
109
|
+
) : (
|
|
110
|
+
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5"><path d="M3 10s3-5 7-5 7 5 7 5-3 5-7 5-7-5-7-5z" /><circle cx="10" cy="10" r="2.5" /><line x1="3" y1="17" x2="17" y2="3" /></svg>
|
|
111
|
+
)}
|
|
112
|
+
</button>
|
|
113
|
+
</div>
|
|
114
|
+
</label>
|
|
115
|
+
|
|
116
|
+
{error ? <p className="form-error">{error}</p> : null}
|
|
117
|
+
|
|
118
|
+
<button type="submit" className="auth-submit" disabled={isPending}>
|
|
119
|
+
{isPending ? "Working..." : isSignUp ? "Create account" : "Sign in"}{" "}
|
|
120
|
+
{!isPending && <span className="auth-submit-arrow">→</span>}
|
|
121
|
+
</button>
|
|
122
|
+
</form>
|
|
123
|
+
|
|
124
|
+
<p className="auth-footer">secured by better-auth</p>
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
|
|
6
|
+
import { authClient } from "@/lib/auth-client";
|
|
7
|
+
|
|
8
|
+
export function SignOutButton() {
|
|
9
|
+
const router = useRouter();
|
|
10
|
+
const [isPending, setIsPending] = useState(false);
|
|
11
|
+
|
|
12
|
+
async function handleSignOut() {
|
|
13
|
+
setIsPending(true);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
await authClient.signOut();
|
|
17
|
+
router.push("/sign-in");
|
|
18
|
+
router.refresh();
|
|
19
|
+
} finally {
|
|
20
|
+
setIsPending(false);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<button type="button" className="__SIGN_OUT_BUTTON_CLASS__" onClick={() => void handleSignOut()}>
|
|
26
|
+
{isPending ? "Signing out..." : "Sign out"}
|
|
27
|
+
</button>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { drizzle } from "__DRIZZLE_DRIVER_IMPORT__";
|
|
4
|
+
__DATABASE_CLIENT_IMPORT__
|
|
5
|
+
|
|
6
|
+
import { env } from "@/lib/env";
|
|
7
|
+
|
|
8
|
+
__DATABASE_GLOBAL_BLOCK__
|
|
9
|
+
|
|
10
|
+
const client = __DATABASE_CLIENT_EXPRESSION__;
|
|
11
|
+
|
|
12
|
+
if (env.NODE_ENV !== "production") {
|
|
13
|
+
__DATABASE_DEV_CACHE__
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const db = __DATABASE_DRIZZLE_EXPRESSION__;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
|
2
|
+
|
|
3
|
+
export const projects = pgTable("projects", {
|
|
4
|
+
id: text("id").primaryKey(),
|
|
5
|
+
name: text("name").notNull(),
|
|
6
|
+
slug: text("slug").notNull().unique(),
|
|
7
|
+
createdAt: timestamp("created_at", {
|
|
8
|
+
withTimezone: true
|
|
9
|
+
})
|
|
10
|
+
.defaultNow()
|
|
11
|
+
.notNull(),
|
|
12
|
+
updatedAt: timestamp("updated_at", {
|
|
13
|
+
withTimezone: true
|
|
14
|
+
})
|
|
15
|
+
.defaultNow()
|
|
16
|
+
.notNull()
|
|
17
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { db } from "@/db";
|
|
2
|
+
import { projects } from "@/db/schema";
|
|
3
|
+
|
|
4
|
+
const shouldSeedDemoData = ["yes"].includes("__SEED_DEMO_DATA__");
|
|
5
|
+
|
|
6
|
+
async function main() {
|
|
7
|
+
if (!shouldSeedDemoData) {
|
|
8
|
+
console.log("Demo seed data disabled.");
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
await db
|
|
13
|
+
.insert(projects)
|
|
14
|
+
.values({
|
|
15
|
+
id: "project-demo",
|
|
16
|
+
name: "Demo Project",
|
|
17
|
+
slug: "demo-project"
|
|
18
|
+
})
|
|
19
|
+
.onConflictDoNothing();
|
|
20
|
+
|
|
21
|
+
console.log("Demo application data seed complete.");
|
|
22
|
+
console.log("Note: auth users are not created by db:seed. Sign up through Better Auth flows.");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
main().catch((error) => {
|
|
26
|
+
console.error("Database seed failed.");
|
|
27
|
+
console.error(
|
|
28
|
+
"Make sure PostgreSQL is running, DATABASE_URL is set, and schema migrations have been applied first."
|
|
29
|
+
);
|
|
30
|
+
console.error(error);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { headers } from "next/headers";
|
|
4
|
+
|
|
5
|
+
import { auth } from "./auth";
|
|
6
|
+
import { env } from "./env";
|
|
7
|
+
|
|
8
|
+
export async function getSession() {
|
|
9
|
+
try {
|
|
10
|
+
return await auth.api.getSession({
|
|
11
|
+
headers: await headers()
|
|
12
|
+
});
|
|
13
|
+
} catch (error) {
|
|
14
|
+
if (env.NODE_ENV !== "production") {
|
|
15
|
+
console.warn("Better Auth session lookup failed. Falling back to anonymous mode.", error);
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { betterAuth } from "better-auth";
|
|
4
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
5
|
+
|
|
6
|
+
import { db } from "../db";
|
|
7
|
+
import { env } from "./env";
|
|
8
|
+
import * as schema from "../db/schema";
|
|
9
|
+
|
|
10
|
+
export const auth = betterAuth({
|
|
11
|
+
appName: "__PROJECT_NAME__",
|
|
12
|
+
baseURL: env.BETTER_AUTH_URL,
|
|
13
|
+
secret: env.BETTER_AUTH_SECRET,
|
|
14
|
+
trustedOrigins: [env.NEXT_PUBLIC_APP_URL],
|
|
15
|
+
database: drizzleAdapter(db, {
|
|
16
|
+
provider: "pg",
|
|
17
|
+
schema
|
|
18
|
+
}),
|
|
19
|
+
emailAndPassword: {
|
|
20
|
+
enabled: true,
|
|
21
|
+
autoSignIn: true
|
|
22
|
+
}__AUTH_SOCIAL_PROVIDERS_BLOCK__
|
|
23
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { env } from "@/lib/env";
|
|
4
|
+
|
|
5
|
+
__BILLING_PROVIDER_IMPORTS__
|
|
6
|
+
|
|
7
|
+
import type { BillingProvider, BillingProviderName } from "./types";
|
|
8
|
+
|
|
9
|
+
export function getBillingProviderName(input?: string): BillingProviderName {
|
|
10
|
+
if (input === "stripe" || input === "polar") {
|
|
11
|
+
return input;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (env.BILLING_PROVIDER === "stripe" || env.BILLING_PROVIDER === "polar") {
|
|
15
|
+
return env.BILLING_PROVIDER;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (env.BILLING_PROVIDER === "both") {
|
|
19
|
+
return "stripe";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
throw new Error("Billing is disabled for this project.");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getBillingProvider(input?: string): BillingProvider {
|
|
26
|
+
const provider = getBillingProviderName(input);
|
|
27
|
+
const billingProviderFactories: Partial<Record<BillingProviderName, () => BillingProvider>> = {
|
|
28
|
+
__BILLING_PROVIDER_FACTORIES__
|
|
29
|
+
};
|
|
30
|
+
const factory = billingProviderFactories[provider];
|
|
31
|
+
|
|
32
|
+
if (!factory) {
|
|
33
|
+
throw new Error(`Billing provider is not installed: ${provider}.`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return factory();
|
|
37
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import { env } from "@/lib/env";
|
|
4
|
+
|
|
5
|
+
import type {
|
|
6
|
+
BillingProvider,
|
|
7
|
+
CheckoutSessionInput,
|
|
8
|
+
PortalSessionInput,
|
|
9
|
+
WebhookInput
|
|
10
|
+
} from "../types";
|
|
11
|
+
|
|
12
|
+
function getPolarApiBaseUrl() {
|
|
13
|
+
return env.POLAR_SERVER === "production"
|
|
14
|
+
? "https://api.polar.sh/v1"
|
|
15
|
+
: "https://sandbox-api.polar.sh/v1";
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function polarFetch<T>(pathname: string, body: Record<string, unknown>) {
|
|
19
|
+
if (!env.POLAR_ACCESS_TOKEN) {
|
|
20
|
+
throw new Error("POLAR_ACCESS_TOKEN is required for Polar billing.");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const response = await fetch(`${getPolarApiBaseUrl()}${pathname}`, {
|
|
24
|
+
method: "POST",
|
|
25
|
+
headers: {
|
|
26
|
+
Authorization: `Bearer ${env.POLAR_ACCESS_TOKEN}`,
|
|
27
|
+
"Content-Type": "application/json"
|
|
28
|
+
},
|
|
29
|
+
body: JSON.stringify(body)
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
if (!response.ok) {
|
|
33
|
+
const message = await response.text();
|
|
34
|
+
throw new Error(`Polar request failed: ${message}`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return (await response.json()) as T;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class PolarBillingProvider implements BillingProvider {
|
|
41
|
+
async createCheckoutSession(input: CheckoutSessionInput) {
|
|
42
|
+
if (!env.POLAR_PRODUCT_ID) {
|
|
43
|
+
throw new Error("POLAR_PRODUCT_ID is required for Polar checkout.");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const checkout = await polarFetch<{ url?: string }>("/checkouts", {
|
|
47
|
+
products: [env.POLAR_PRODUCT_ID],
|
|
48
|
+
external_customer_id: input.userId,
|
|
49
|
+
customer_email: input.customerEmail,
|
|
50
|
+
success_url: input.successUrl,
|
|
51
|
+
metadata: {
|
|
52
|
+
userId: input.userId
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
if (!checkout.url) {
|
|
57
|
+
throw new Error("Polar did not return a checkout URL.");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return { url: checkout.url };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async createCustomerPortalSession(input: PortalSessionInput) {
|
|
64
|
+
const session = await polarFetch<{ customer_portal_url?: string }>("/customer-sessions/", {
|
|
65
|
+
external_customer_id: input.userId
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
if (!session.customer_portal_url) {
|
|
69
|
+
throw new Error("Polar did not return a customer portal URL.");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { url: session.customer_portal_url };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async handleWebhook(_input: WebhookInput) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
"Polar webhook verification is intentionally left for follow-up implementation after confirming your webhook signing strategy."
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import "server-only";
|
|
2
|
+
|
|
3
|
+
import Stripe from "stripe";
|
|
4
|
+
|
|
5
|
+
import { env } from "@/lib/env";
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
BillingProvider,
|
|
9
|
+
CheckoutSessionInput,
|
|
10
|
+
PortalSessionInput,
|
|
11
|
+
WebhookInput
|
|
12
|
+
} from "../types";
|
|
13
|
+
|
|
14
|
+
let stripeClient: Stripe | null = null;
|
|
15
|
+
|
|
16
|
+
function getStripe() {
|
|
17
|
+
if (!env.STRIPE_SECRET_KEY) {
|
|
18
|
+
throw new Error("STRIPE_SECRET_KEY is required for Stripe billing.");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
stripeClient ??= new Stripe(env.STRIPE_SECRET_KEY);
|
|
22
|
+
return stripeClient;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class StripeBillingProvider implements BillingProvider {
|
|
26
|
+
async createCheckoutSession(input: CheckoutSessionInput) {
|
|
27
|
+
if (!env.STRIPE_PRICE_ID) {
|
|
28
|
+
throw new Error("STRIPE_PRICE_ID is required for Stripe checkout.");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const stripe = getStripe();
|
|
32
|
+
const session = await stripe.checkout.sessions.create({
|
|
33
|
+
mode: "subscription",
|
|
34
|
+
line_items: [
|
|
35
|
+
{
|
|
36
|
+
price: env.STRIPE_PRICE_ID,
|
|
37
|
+
quantity: 1
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
customer_email: input.customerEmail,
|
|
41
|
+
success_url: input.successUrl,
|
|
42
|
+
cancel_url: input.cancelUrl,
|
|
43
|
+
allow_promotion_codes: true,
|
|
44
|
+
metadata: {
|
|
45
|
+
userId: input.userId
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!session.url) {
|
|
50
|
+
throw new Error("Stripe did not return a checkout URL.");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { url: session.url };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async createCustomerPortalSession(
|
|
57
|
+
_input: PortalSessionInput
|
|
58
|
+
): Promise<{ url: string }> {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"Stripe portal requires a persisted Stripe customer ID. Add customer mapping before enabling portal access."
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async handleWebhook(input: WebhookInput) {
|
|
65
|
+
if (!env.STRIPE_WEBHOOK_SECRET) {
|
|
66
|
+
throw new Error("STRIPE_WEBHOOK_SECRET is required for Stripe webhooks.");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const signature = input.headers.get("stripe-signature");
|
|
70
|
+
if (!signature) {
|
|
71
|
+
throw new Error("Missing Stripe signature header.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const stripe = getStripe();
|
|
75
|
+
stripe.webhooks.constructEvent(input.rawBody, signature, env.STRIPE_WEBHOOK_SECRET);
|
|
76
|
+
}
|
|
77
|
+
}
|