Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions .cursor/rules/zero-locker-rules.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ alwaysApply: true
### Files
- **Components**: `kebab-case.tsx` (e.g., `dashboard-credential-form.tsx`)
- **Pages**: `page.tsx` or `route.ts` for API routes
- **Schemas**: `kebab-case.ts` (e.g., `credential-form-dto.ts`)
- **Schemas**: `kebab-case.ts` (e.g., `credential-form-dto.ts`)
- **Entities**: `entity.ts`, `query.ts`, `index.ts`
- **Hooks**: `use-kebab-case.ts` (e.g., `use-copy-to-clipboard.ts`)
- **Types**: `kebab-case.d.ts` (e.g., `dashboard.d.ts`)
Expand Down Expand Up @@ -72,12 +72,14 @@ orpc/
```
schemas/
├── credential/
│ ├── credential.ts # Entity schemas
│ ├── credential-metadata.ts # Metadata schemas
│ └── dto.ts # DTO schemas
│ ├── enums.ts
│ ├── input.ts
│ ├── index.ts
│ └── output.ts
├── utils/
│ ├── base-key-value-pair.ts
│ └── utils.ts
│ ├── input.ts
│ ├── index.ts
│ └── output.ts
└── index.ts
```

Expand Down Expand Up @@ -256,6 +258,7 @@ export function createCredential(input: CreateCredentialInput) {
### Input Validation
- **Validate all inputs** with Zod schemas
- **Sanitize user input** before processing
- **File structure** all Zod schemas must be stored in the `schemas` folder
- **Use parameterized queries** for database operations

```tsx
Expand Down
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,9 @@ LOGO_DEV_TOKEN=""

# Resend Key
RESEND_API_KEY=""
MARKETING_SUBSCRIPTION_EMAIL=""
MARKETING_SUBSCRIPTION_EMAIL=""

# LemonSqueezy Keys
LEMON_SQUEEZY_API_KEY=""
LEMON_SQUEEZY_STORE_ID=""
LEMON_SQUEEZY_WEBHOOK_SECRET=""
31 changes: 0 additions & 31 deletions .eslintrc.json

This file was deleted.

4 changes: 3 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ node_modules
next-env.d.ts
next.config.ts
yarn.lock
components/ui
components/ui
data/**/*.mdx
lib/.content-collections
109 changes: 109 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ BETTER_AUTH_SECRET=your-secret-key-minimum-32-characters-for-production
# Optional: Logo.dev API (for fetching website logos)
LOGO_DEV_TOKEN=your-logo-dev-token
NEXT_PUBLIC_LOGO_DEV_TOKEN=your-public-logo-dev-token

# Lemon Squeezy Integration (for payments)
LEMON_SQUEEZY_API_KEY=your-lemon-squeezy-api-key
LEMON_SQUEEZY_STORE_ID=your-store-id
LEMON_SQUEEZY_WEBHOOK_SECRET=your-webhook-secret
```

> ⚠️ **Security Note**: The `BETTER_AUTH_SECRET` should be a strong, random string (minimum 32 characters) in production. Generate one using:
Expand Down Expand Up @@ -220,6 +225,110 @@ The development server includes:
- 🐛 Detailed error messages
- 📊 React Query DevTools (bottom-left corner)

## 🔗 Webhook Development with ngrok

Zero Locker integrates with Lemon Squeezy for payment processing. To test webhooks locally, you'll need to expose your local development server to the internet using ngrok.

### Prerequisites

1. **Install ngrok**: Download from [ngrok.com](https://ngrok.com/download) or install via package manager:

```bash
# macOS with Homebrew
brew install ngrok

# Or download from https://ngrok.com/download
```

2. **Sign up for ngrok**: Create a free account at [ngrok.com](https://ngrok.com) and get your auth token.

3. **Authenticate ngrok**:
```bash
ngrok config add-authtoken <YOUR_AUTH_TOKEN>
```

### Running with Webhook Support

#### Option 1: Run Both Services Together

```bash
# Start both Next.js dev server and ngrok tunnel
pnpm webhook:dev
```

This will:

- Start Next.js on `http://localhost:3000`
- Create an ngrok tunnel (e.g., `https://abc123.ngrok.io`)
- Display both URLs in the terminal

#### Option 2: Run Services Separately

```bash
# Terminal 1: Start Next.js development server
pnpm dev

# Terminal 2: Start ngrok tunnel
pnpm webhook:tunnel
```

### Setting Up Lemon Squeezy Webhooks

1. **Get your ngrok URL**: After running `pnpm webhook:tunnel`, you'll see output like:

```
Forwarding https://abc123.ngrok.io -> http://localhost:3000
```

2. **Configure Lemon Squeezy Webhook**:

- Go to your Lemon Squeezy dashboard
- Navigate to Settings → Webhooks
- Add a new webhook with URL: `https://abc123.ngrok.io/api/orpc/webhooks.handle`
- Select events: `subscription_created`, `subscription_updated`, `subscription_cancelled`, etc.

3. **Test the Webhook**:
- Create a test subscription in Lemon Squeezy
- Check your terminal logs for webhook events
- Verify data is stored in your database

### Testing Webhooks Locally

You can test the webhook integration locally using the included test script:

```bash
# Make sure your dev server is running
pnpm dev

# In another terminal, run the webhook test
pnpm test:webhook
```

This will:

- Send a mock `subscription_created` event to your local webhook endpoint
- Verify the webhook signature validation
- Test the subscription processing logic
- Show detailed response information

### Webhook Endpoint

The webhook endpoint is available at:

```
POST /api/orpc/webhooks.handle
```

This endpoint:

- ✅ **Verifies Lemon Squeezy webhook signatures** (critical security feature)
- ✅ Processes subscription events (created, updated, cancelled, etc.)
- ✅ Updates user subscription status in the database
- ✅ Handles payment events (success, failed, recovered)
- ✅ Provides detailed logging for debugging

**Security Note**: The webhook signature verification is handled at the router level using middleware. The `webhookSignatureMiddleware` verifies the `X-Signature` header using HMAC-SHA256 with your webhook secret before processing any webhook requests. This ensures only legitimate requests from Lemon Squeezy are processed, preventing unauthorized access to your subscription data.

## 📝 License

This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.
47 changes: 47 additions & 0 deletions app/(account)/account/invoices/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Metadata } from "next"
import { headers } from "next/headers"
import { notFound, redirect } from "next/navigation"
import { createServerClient } from "@/orpc/client/server"
import { createContext } from "@/orpc/context"

import { auth } from "@/lib/auth/server"

import { AccountInvoiceDetailClient } from "@/components/app/account-invoice-detail-client"

export async function generateMetadata(): Promise<Metadata> {
return {
title: `Invoice Details`,
}
}

export default async function InvoiceDetailPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const session = await auth.api.getSession({
headers: await headers(),
})

if (!session) {
redirect("/login")
}

const { id } = await params

const context = await createContext()
const serverClient = createServerClient(context)

try {
const invoiceResponse = await serverClient.subscriptions.getInvoice({ id })

return (
<AccountInvoiceDetailClient
invoiceId={id}
initialData={invoiceResponse}
/>
)
} catch {
notFound()
}
}
33 changes: 33 additions & 0 deletions app/(account)/account/invoices/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { Metadata } from "next"
import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { createServerClient } from "@/orpc/client/server"
import { createContext } from "@/orpc/context"

import { auth } from "@/lib/auth/server"

import { AccountInvoicesClient } from "@/components/app/account-invoices-client"

export const metadata: Metadata = {
title: "Invoices",
}

export default async function InvoicesPage() {
const session = await auth.api.getSession({
headers: await headers(),
})

if (!session) {
redirect("/login")
}

const context = await createContext()
const serverClient = createServerClient(context)

const subscriptionsResponse = await serverClient.subscriptions.list({
page: 1,
limit: 100,
})

return <AccountInvoicesClient initialSubscriptions={subscriptionsResponse} />
}
42 changes: 42 additions & 0 deletions app/(account)/account/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { headers } from "next/headers"
import { redirect } from "next/navigation"

import { auth } from "@/lib/auth/server"

import { AccountMobileHeader } from "@/components/layout/account-mobile-header"
import { AccountSidebar } from "@/components/layout/account-sidebar"
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"

export default async function AccountLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth.api.getSession({
headers: await headers(),
})

if (!session) {
redirect("/login")
}

return (
<SidebarProvider
style={
{
"--sidebar-width": "28rem",
} as React.CSSProperties
}
>
<AccountMobileHeader />
<AccountSidebar />
<SidebarInset>
<main className="flex flex-1 flex-col">
<div className="mx-auto w-full max-w-7xl flex-1 px-4 py-6 md:py-10 lg:py-12">
{children}
</div>
</main>
</SidebarInset>
</SidebarProvider>
)
}
30 changes: 30 additions & 0 deletions app/(account)/account/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Metadata } from "next"
import { headers } from "next/headers"
import { redirect } from "next/navigation"
import { createServerClient } from "@/orpc/client/server"
import { createContext } from "@/orpc/context"

import { auth } from "@/lib/auth/server"

import { AccountGeneralClient } from "@/components/app/account-general-client"

export const metadata: Metadata = {
title: "General",
}

export default async function AccountGeneralPage() {
const session = await auth.api.getSession({
headers: await headers(),
})

if (!session) {
redirect("/login")
}

const context = await createContext()
const serverClient = createServerClient(context)

const userResponse = await serverClient.users.getCurrentUser({})

return <AccountGeneralClient initialUser={userResponse} />
}
Loading