npm package: @revnu/auth
Description: Authentication SDK for Revnu web app products
Latest version: 1.1.0

# @revnu/auth

Authentication SDK for Revnu web app products. Add sign-in and subscription access control to your **Next.js**, **React + Vite**, or **Hono** app with minimal setup.

## How It Works

Revnu uses a **purchase-first** authentication model:

1. **User purchases** your product via Stripe checkout
2. **Account created automatically** - Revnu creates their account using their email
3. **Setup email sent** - User receives "Set up your password" email with a magic link
4. **User sets password** - They click the link and create their password
5. **Ready to sign in** - From then on, they use email/password to sign in

This means **no separate sign-up flow** - purchasing IS signing up. Returning customers who already have a password just get an "Access granted" email instead.

## Features

- Multi-framework support: Next.js, React + Vite SPA, Hono
- Pre-built `<SignIn />`, `<SignInButton />`, `<SetPassword />`, `<ForgotPassword />`, `<UserButton />`, and `<Avatar />` components
- Auth guard components: `<SignedIn />`, `<SignedOut />`, `<Protect />`
- Automatic store branding on sign-in (logo + store name)
- `<SignInButton />` with modal or redirect mode
- Customizable appearance (colors, border radius, fonts)
- `useRevnuAuth()` hook for auth state and access checks
- Server-side helpers (`getUser`, `checkAccess`, `requireAuth`) for Next.js
- Hono middleware (`revnuMiddleware`, `requireAuth`, `requireProductAccess`)
- Core utilities for custom integrations (`verifyToken`, `hasProductAccess`)
- RS256 cryptographic signature verification (built-in, no keys to configure)
- JWT-based authentication with embedded product access
- No webhook setup required
- TypeScript support

## Installation

```bash
npm install @revnu/auth
# or
bun add @revnu/auth
# or
yarn add @revnu/auth
```

## Subpath Exports

| Import | Use in | Description |
|--------|--------|-------------|
| `@revnu/auth` | React client components | Provider, hooks, components, auth guards |
| `@revnu/auth/core` | Any JS environment | Token verification, access checks (framework-agnostic) |
| `@revnu/auth/nextjs` | Next.js server | `getUser`, `checkAccess`, `requireAuth`, `getAuth`, middleware |
| `@revnu/auth/hono` | Hono server | `revnuMiddleware`, `getAuth`, `requireAuth`, `requireProductAccess` |
| `@revnu/auth/server` | Next.js server | Alias for `@revnu/auth/nextjs` (backward compat) |
| `@revnu/auth/middleware` | Next.js middleware | `withRevnuAuth` middleware helper |
| `@revnu/auth/proxy` | Next.js 16+ proxy | `withRevnuAuth` proxy helper |

## Quick Start (Next.js)

### 1. Get Your Public Key

1. Log into your Revnu dashboard
2. Go to **Settings > Developers > Auth SDK**
3. Click **Generate Public Key**
4. Copy the key (format: `rev_pub_xxxxxxxxxxxxx`)

### 2. Set Environment Variable

```env
NEXT_PUBLIC_REVNU_KEY=rev_pub_xxxxxxxxxxxxx
```

### 3. Add the Provider

Wrap your app with `RevnuAuthProvider` in your root layout:

```tsx
// app/layout.tsx
import { RevnuAuthProvider } from '@revnu/auth';

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body>
        <RevnuAuthProvider>
          {children}
        </RevnuAuthProvider>
      </body>
    </html>
  );
}
```

### 4. Create Auth Pages

**Sign In Page** (required):

```tsx
// app/auth/sign-in/page.tsx
import { SignIn } from '@revnu/auth';

export default function SignInPage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <SignIn redirectTo="/dashboard" />
    </div>
  );
}
```

**Password Setup Page** (required - for new customers):

```tsx
// app/auth/setup/page.tsx
import { SetPassword } from '@revnu/auth';

export default function SetupPage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <SetPassword redirectTo="/dashboard" />
    </div>
  );
}
```

**Forgot Password Page** (recommended):

```tsx
// app/auth/forgot-password/page.tsx
import { ForgotPassword } from '@revnu/auth';

export default function ForgotPasswordPage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <ForgotPassword />
    </div>
  );
}
```

**Reset Password Page** (required if using forgot password):

```tsx
// app/auth/reset-password/page.tsx
import { ResetPassword } from '@revnu/auth';

export default function ResetPasswordPage() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <ResetPassword redirectTo="/dashboard" />
    </div>
  );
}
```

### 5. Protect Pages

**Client-side (React components):**

```tsx
'use client';

import { useRevnuAuth } from '@revnu/auth';
import { redirect } from 'next/navigation';

const PRODUCT_ID = "your-product-id"; // From Revnu dashboard

export default function Dashboard() {
  const { user, isLoading, isAuthenticated, checkAccess } = useRevnuAuth();

  if (isLoading) return <div>Loading...</div>;
  if (!isAuthenticated) redirect('/auth/sign-in');

  const hasPro = checkAccess(PRODUCT_ID);

  return (
    <div>
      <h1>Welcome, {user?.name || user?.email}!</h1>
      {hasPro ? <ProFeatures /> : <UpgradePrompt />}
    </div>
  );
}
```

**Server-side (Server Components):**

```tsx
// app/dashboard/page.tsx
import { getUser, checkAccess } from '@revnu/auth/nextjs';
import { redirect } from 'next/navigation';

const PRODUCT_ID = "your-product-id";

export default async function Dashboard() {
  const user = await getUser();
  if (!user) redirect('/auth/sign-in');

  const hasPro = await checkAccess(PRODUCT_ID);

  return (
    <div>
      <h1>Welcome, {user.name || user.email}!</h1>
      {hasPro ? <ProFeatures /> : <UpgradePrompt />}
    </div>
  );
}
```

**Shorthand helpers:**

```tsx
import { requireAuth, requireAccess } from '@revnu/auth/nextjs';

// Redirects to /auth/sign-in if not authenticated
const user = await requireAuth();

// Redirects to /auth/sign-in if no access to product
const user = await requireAccess(PRODUCT_ID);

// Custom redirect URL
const user = await requireAuth('/login');
const user = await requireAccess(PRODUCT_ID, '/upgrade');
```

## Quick Start (React + Vite)

### 1. Set Environment Variable

```env
VITE_REVNU_KEY=rev_pub_xxxxxxxxxxxxx
```

### 2. Add the Provider

Wrap your app in `main.tsx`:

```tsx
// main.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { RevnuAuthProvider } from '@revnu/auth';
import App from './App';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <React.StrictMode>
    <BrowserRouter>
      <RevnuAuthProvider publicKey={import.meta.env.VITE_REVNU_KEY}>
        <App />
      </RevnuAuthProvider>
    </BrowserRouter>
  </React.StrictMode>
);
```

### 3. Create Auth Pages

Create React Router pages for `/sign-in`, `/auth/setup`, `/auth/forgot-password`, and `/auth/reset-password` using the same components as Next.js (see above).

### 4. Protect Pages

Use auth guard components or the `useRevnuAuth()` hook:

```tsx
import { SignedIn, SignedOut, Protect, SignIn } from '@revnu/auth';

const PRODUCT_ID = "your-product-id";

export default function Dashboard() {
  return (
    <>
      <SignedOut>
        <SignIn redirectTo="/dashboard" />
      </SignedOut>
      <SignedIn>
        <Protect productId={PRODUCT_ID} fallback={<UpgradePrompt />}>
          <div>Protected content here</div>
        </Protect>
      </SignedIn>
    </>
  );
}
```

> **Note:** React + Vite apps are client-only. Do NOT import from `@revnu/auth/server` or `@revnu/auth/nextjs`.

## Quick Start (Hono)

### 1. Set Environment Variable

```env
REVNU_KEY=rev_pub_xxxxxxxxxxxxx
```

### 2. Add Middleware and Protect Routes

```typescript
import { Hono } from 'hono';
import { revnuMiddleware, getAuth, requireAuth, requireProductAccess } from '@revnu/auth/hono';

const app = new Hono();

// Extract and validate JWT on all routes
app.use('*', revnuMiddleware());

// Public route — auth available but not required
app.get('/', (c) => {
  const auth = getAuth(c);
  if (auth?.user) {
    return c.json({ message: `Hello, ${auth.user.email}!` });
  }
  return c.json({ message: 'Hello, guest!' });
});

// Protected route — requires authentication (returns 401)
app.get('/api/profile', requireAuth(), (c) => {
  const auth = getAuth(c);
  return c.json({ user: auth!.user });
});

// Product-gated route — requires product access (returns 403)
app.get('/api/premium', requireProductAccess("your-product-id"), (c) => {
  const auth = getAuth(c);
  return c.json({ data: 'Premium content', user: auth!.user });
});

export default app;
```

> **Note:** Hono uses server-side middleware only. Do NOT import React components from `@revnu/auth`.

## API Reference

### Auth Guard Components

Components for declarative auth protection (client-side, React):

#### `<SignedIn>`

Renders children only when the user is authenticated.

```tsx
import { SignedIn } from '@revnu/auth';

<SignedIn>
  <p>You are signed in!</p>
</SignedIn>
```

#### `<SignedOut>`

Renders children only when the user is NOT authenticated.

```tsx
import { SignedOut } from '@revnu/auth';

<SignedOut>
  <p>Please sign in to continue.</p>
</SignedOut>
```

#### `<Protect>`

Renders children only when the user has access to a specific product. Shows `fallback` when access is denied.

```tsx
import { Protect } from '@revnu/auth';

<Protect productId="prod_abc123" fallback={<UpgradePrompt />}>
  <PremiumFeature />
</Protect>
```

| Prop | Type | Description |
|------|------|-------------|
| `productId` | `string` | Product ID to check access for |
| `fallback` | `ReactNode` | Content to show when access is denied |
| `children` | `ReactNode` | Content to show when access is granted |

### Components

#### `<RevnuAuthProvider>`

Wrap your app with this provider. Reads `NEXT_PUBLIC_REVNU_KEY` (Next.js) or accepts `publicKey` prop (Vite).

```tsx
<RevnuAuthProvider
  publicKey="rev_pub_xxx"  // Optional, defaults to env var
  authUrl="https://custom.api.com"  // Optional, for custom auth API
  onAuthStateChange={(user) => console.log(user)}  // Optional callback
>
  {children}
</RevnuAuthProvider>
```

#### `<SignIn />`

Pre-built sign-in form.

```tsx
<SignIn
  redirectTo="/dashboard"  // Where to go after sign-in
  onSuccess={(user) => {}}  // Callback on success
  onError={(error) => {}}   // Callback on error
  className="custom-class"  // Custom styling
  initialValues={{ email: 'user@example.com' }}  // Pre-fill email
  appearance={{  // Customize colors, borders, fonts
    variables: {
      colorPrimary: '#6366f1',
      borderRadius: '8px',
    }
  }}
/>
```

#### `<SignInButton />`

Button that triggers sign-in via modal or redirect.

**Redirect mode** (navigates to sign-in page):

```tsx
<SignInButton mode="redirect" redirectUrl="/auth/sign-in">
  Sign in
</SignInButton>
```

**Modal mode** (opens sign-in form in overlay):

```tsx
<SignInButton
  mode="modal"
  afterSignInUrl="/dashboard"  // Where to go after sign-in
  onSuccess={(user) => {}}      // Callback on success
  onError={(error) => {}}       // Callback on error
  appearance={{                 // Passed to SignIn component
    variables: { colorPrimary: '#6366f1' }
  }}
  initialValues={{ email: 'user@example.com' }}
  className="my-button-class"   // Style the button
>
  Sign in
</SignInButton>
```

The modal:
- Opens as an overlay with backdrop blur
- Closes on Escape key or clicking outside
- Has a close button (X)
- Prevents body scroll when open

#### `<SetPassword />`

Password setup form for new users. Reads `token` from URL query params automatically.

When a user purchases your product, they receive an email with a link like:
`https://yourapp.com/auth/setup?token=xxx`

This component handles that token and lets them set their password.

```tsx
<SetPassword
  redirectTo="/dashboard"  // Where to go after setup
  onSuccess={(user) => {}}  // Callback on success
  onError={(error) => {}}   // Callback on error
  className="custom-class"
  appearance={{ variables: { colorPrimary: '#6366f1' } }}
/>
```

**What happens with expired/invalid tokens:**
- If the token is expired, the component automatically shows a "Request new link" form
- If the user already has a password, they're redirected to sign in

#### `<RequestSetupLink />`

Form for users to request a new setup link (when their original link expired).

```tsx
<RequestSetupLink
  onSuccess={() => {}}  // Callback on success
  onError={(error) => {}}
  className="custom-class"
  appearance={{ variables: { colorPrimary: '#6366f1' } }}
/>
```

#### `<ForgotPassword />`

Form for existing users to request a password reset email.

```tsx
<ForgotPassword
  onSuccess={() => {}}  // Callback on success
  onError={(error) => {}}
  className="custom-class"
  appearance={{ variables: { colorPrimary: '#6366f1' } }}
/>
```

#### `<ResetPassword />`

Password reset form. Reads `token` from URL query params automatically.

```tsx
<ResetPassword
  redirectTo="/dashboard"  // Where to go after reset
  onSuccess={(user) => {}}
  onError={(error) => {}}
  className="custom-class"
  appearance={{ variables: { colorPrimary: '#6366f1' } }}
/>
```

#### `<UserButton />`

User avatar with dropdown menu (sign out, etc.).

```tsx
<UserButton
  afterSignOutUrl="/"  // Where to go after sign-out
  className="custom-class"
  appearance={{ variables: { colorPrimary: '#6366f1' } }}
/>
```

#### `<Avatar />`

Standalone avatar component showing user initials or an image.

```tsx
import { Avatar } from '@revnu/auth';

// With user (shows initials)
<Avatar user={user} />

// With image
<Avatar user={user} imageUrl="https://example.com/photo.jpg" />

// Sizes: 'small' (24px), 'medium' (32px, default), 'large' (40px), or custom number
<Avatar user={user} size="large" />
<Avatar user={user} size={48} />
```

| Prop | Type | Description |
|------|------|-------------|
| `user` | `{ name?: string; email: string } \| null` | User for initials |
| `imageUrl` | `string` | Image URL (overrides initials) |
| `size` | `'small' \| 'medium' \| 'large' \| number` | Size (default: `'medium'`) |
| `className` | `string` | Custom CSS class |

### Customization

All components accept an `appearance` prop for styling customization.

The default `borderRadius` is `8px` (rounded). Pass `0px` for square corners.

#### Appearance Variables

```tsx
<SignIn
  appearance={{
    variables: {
      // Colors
      colorPrimary: '#6366f1',      // Buttons, focus rings
      colorError: '#dc2626',         // Error messages
      colorText: '#18181b',          // Main text
      colorTextSecondary: '#71717a', // Subtitles, hints
      colorBackground: '#ffffff',    // Card background
      colorBorder: '#e5e7eb',        // Input borders
      colorInputBackground: 'transparent',

      // Shape
      borderRadius: '8px',           // 0px for square, 8px for rounded

      // Typography
      fontFamily: 'Inter, sans-serif',
    }
  }}
/>
```

#### Dark Mode

The components automatically adapt to dark mode using `prefers-color-scheme`. Custom colors you set will be used as the base, with automatic dark mode variants applied.

#### Example: Rounded Purple Theme

```tsx
const purpleTheme = {
  variables: {
    colorPrimary: '#7c3aed',
    colorError: '#ef4444',
    borderRadius: '12px',
    fontFamily: 'Inter, system-ui, sans-serif',
  }
};

// Use across all components
<SignIn appearance={purpleTheme} />
<SetPassword appearance={purpleTheme} />
<ForgotPassword appearance={purpleTheme} />
```

#### Example: Square Minimal Theme

```tsx
const squareTheme = {
  variables: {
    colorPrimary: '#000000',
    borderRadius: '0px',  // Override the default 8px
  }
};

<SignIn appearance={squareTheme} />
```

### Hooks

#### `useRevnuAuth()`

Access auth state and methods.

```tsx
const {
  user,           // RevnuUser | null
  isLoading,      // boolean
  isAuthenticated, // boolean
  signIn,         // { email: (credentials) => Promise<AuthResponse> }
  signOut,        // () => Promise<void>
  checkAccess,    // (productId: string | string[]) => boolean
  refreshSession, // () => Promise<void>
} = useRevnuAuth();
```

**Programmatic sign-in:**

```tsx
const { signIn } = useRevnuAuth();

const result = await signIn.email({
  email: 'user@example.com',
  password: 'password123',
});

if (result.success) {
  console.log('Signed in:', result.user);
} else {
  console.error('Error:', result.error);
}
```

#### `useStoreInfo()`

Get the store info (name, logo) for the current creator.

```tsx
import { useStoreInfo } from '@revnu/auth';

const storeInfo = useStoreInfo();
// { name: "My Store", logoUrl: "https://..." } | null
```

### Server Functions (Next.js)

Import from `@revnu/auth/nextjs` (or `@revnu/auth/server`):

```tsx
import {
  getUser,
  getAuth,
  checkAccess,
  getToken,
  requireAuth,
  requireAccess,
} from '@revnu/auth/nextjs';
```

#### `getUser()`

Get the current user from cookies. Returns `null` if not authenticated.

```tsx
const user = await getUser();
// { id, email, name, createdAt, products: [...] } | null
```

#### `getAuth()`

Get the full auth object (Clerk-inspired API).

```tsx
const auth = await getAuth();
// { user, token, checkAccess } | null
```

#### `checkAccess(productId)`

Check if the user has access to a product.

```tsx
const hasAccess = await checkAccess("product-id");
// or check multiple products (returns true if ANY match)
const hasAccess = await checkAccess(["product-1", "product-2"]);
```

#### `getToken()`

Get the raw JWT token (for passing to external APIs).

```tsx
const token = await getToken();
// "eyJhbG..." | null
```

#### `requireAuth(redirectTo?)`

Get user or redirect if not authenticated.

```tsx
const user = await requireAuth();  // Redirects to /auth/sign-in
const user = await requireAuth('/login');  // Custom redirect
```

#### `requireAccess(productId, redirectTo?)`

Get user or redirect if no access.

```tsx
const user = await requireAccess("product-id");
const user = await requireAccess("product-id", '/upgrade');
```

### Hono Middleware

Import from `@revnu/auth/hono`:

```tsx
import { revnuMiddleware, getAuth, requireAuth, requireProductAccess } from '@revnu/auth/hono';
```

#### `revnuMiddleware()`

Extracts JWT from cookies/Authorization header, validates it, sets auth in context. Does NOT reject unauthenticated requests.

#### `getAuth(c)`

Returns the auth object from Hono context: `{ user, token, checkAccess }`. Returns `null` if not authenticated.

#### `requireAuth()`

Middleware that returns 401 if not authenticated.

#### `requireProductAccess(productId)`

Middleware that returns 401 if not authenticated, 403 if no access to the specified product.

### Core Utilities

Import from `@revnu/auth/core` for framework-agnostic utilities:

```tsx
import {
  verifyToken,
  getUserFromToken,
  checkTokenAccess,
  hasProductAccess,
  extractToken,
  getAuthFromToken,
  matchPath,
  isPublicPath,
} from '@revnu/auth/core';
```

### Proxy / Middleware (Next.js)

Protect routes automatically with Next.js proxy (or middleware for older versions).

**Next.js 16+** (uses `proxy.ts`):

```tsx
// proxy.ts
import { withRevnuAuth } from '@revnu/auth/proxy';

export default withRevnuAuth({
  publicRoutes: ['/', '/pricing', '/auth/sign-in', '/auth/setup', '/auth/forgot-password', '/auth/reset-password'],
  // All other routes require authentication
});

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
```

**Next.js 14-15** (uses `middleware.ts`):

```tsx
// middleware.ts
import { withRevnuAuth } from '@revnu/auth/middleware';

export default withRevnuAuth({
  publicRoutes: ['/', '/pricing', '/auth/sign-in', '/auth/setup', '/auth/forgot-password', '/auth/reset-password'],
  // All other routes require authentication
});

export const config = {
  matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
};
```

> **Note:** Next.js 16 renamed `middleware.ts` to `proxy.ts`. Both imports (`@revnu/auth/proxy` and `@revnu/auth/middleware`) export the same `withRevnuAuth` helper - just use the one matching your file name.

## Required Routes

Your app needs these auth routes for the full flow:

| Route | Component | Purpose |
|-------|-----------|---------|
| `/auth/sign-in` | `<SignIn />` | Existing users sign in |
| `/auth/setup` | `<SetPassword />` | New users set their password |
| `/auth/forgot-password` | `<ForgotPassword />` | Request password reset |
| `/auth/reset-password` | `<ResetPassword />` | Reset password with token |

## Types

```typescript
interface RevnuUser {
  id: string;
  email: string;
  name?: string;
  createdAt: number;
  products: ProductAccess[];
}

interface ProductAccess {
  productId: string;
  name: string;
  status: 'active' | 'cancelled' | 'past_due' | 'paused';
  cancelAtPeriodEnd: boolean;
  currentPeriodEnd?: number;  // Unix timestamp
  purchasedAt: number;        // Unix timestamp
}

interface AuthResponse {
  success: boolean;
  user?: RevnuUser;
  error?: string;
}

interface RevnuAuth {
  user: RevnuUser | null;
  token: string | null;
  checkAccess: (productId: string | string[]) => boolean;
}

interface Appearance {
  variables?: AppearanceVariables;
}

interface AppearanceVariables {
  colorPrimary?: string;
  colorError?: string;
  colorText?: string;
  colorTextSecondary?: string;
  colorBackground?: string;
  colorBorder?: string;
  colorInputBackground?: string;
  borderRadius?: string;
  fontFamily?: string;
}

interface SignInInitialValues {
  email?: string;
}
```

## Access Check Logic

`checkAccess()` returns `true` if the user has:
- An **active** subscription to the product, OR
- A **cancelled** subscription that hasn't reached `currentPeriodEnd` yet

## Environment Variables

| Variable | Framework | Required | Description |
|----------|-----------|----------|-------------|
| `NEXT_PUBLIC_REVNU_KEY` | Next.js | Yes | Public API key from Revnu dashboard |
| `VITE_REVNU_KEY` | React + Vite | Yes | Public API key from Revnu dashboard |
| `REVNU_KEY` | Hono / Other | Yes | Public API key from Revnu dashboard |
| `VITE_REVNU_AUTH_URL` | React + Vite | No | Custom auth API URL (defaults to Revnu) |

Just one environment variable per framework. The SDK uses RS256 asymmetric cryptography with an embedded public key, so no secrets need to be configured in your app.

## Product IDs

Find product IDs in your Revnu dashboard:
1. Go to **Products**
2. Click on a product
3. Copy the ID from the URL

## User Flow Diagram

```
┌─────────────────┐
│  User visits    │
│  your checkout  │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│ Completes Stripe│
│    checkout     │
└────────┬────────┘
         │
         ▼
┌─────────────────┐
│  Revnu creates  │
│    account      │
└────────┬────────┘
         │
         ▼
┌─────────────────────────────────────┐
│          Is this a new user?        │
└──────────┬─────────────┬────────────┘
           │ Yes         │ No (has password)
           ▼             ▼
┌─────────────────┐  ┌─────────────────┐
│ "Set up your    │  │ "You now have   │
│  password" email│  │  access" email  │
└────────┬────────┘  └────────┬────────┘
         │                    │
         ▼                    ▼
┌─────────────────┐  ┌─────────────────┐
│  /auth/setup    │  │  /auth/sign-in  │
│  <SetPassword/> │  │   <SignIn />    │
└────────┬────────┘  └────────┬────────┘
         │                    │
         └────────┬───────────┘
                  ▼
         ┌─────────────────┐
         │    Dashboard    │
         └─────────────────┘
```

## Troubleshooting

**"Missing public key" error**
- Ensure your env var is set (`NEXT_PUBLIC_REVNU_KEY`, `VITE_REVNU_KEY`, or `REVNU_KEY`)
- Restart your dev server after adding env vars

**User is null after sign-in**
- Check browser localStorage for `revnu_access_token`
- Try clearing localStorage and signing in again

**checkAccess always returns false**
- Verify the product ID is correct
- Ensure the user has purchased the product
- Check the purchase status is "active"

**Setup link expired**
- The `<SetPassword />` component automatically shows a "Request new link" form
- Users can enter their email to receive a fresh setup link

**User purchased but didn't receive setup email**
- Check spam folder
- Verify the email address was correct at checkout
- User can request a new setup link from `/auth/setup` with an expired token

## License

MIT