Repository: nextjs/saas-starter Branch: main Commit: 6e33e58b1e55 Files: 52 Total size: 133.7 KB Directory structure: gitextract_dakwwhzc/ ├── .gitignore ├── LICENSE ├── README.md ├── app/ │ ├── (dashboard)/ │ │ ├── dashboard/ │ │ │ ├── activity/ │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ ├── general/ │ │ │ │ └── page.tsx │ │ │ ├── layout.tsx │ │ │ ├── page.tsx │ │ │ └── security/ │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── pricing/ │ │ │ ├── page.tsx │ │ │ └── submit-button.tsx │ │ └── terminal.tsx │ ├── (login)/ │ │ ├── actions.ts │ │ ├── login.tsx │ │ ├── sign-in/ │ │ │ └── page.tsx │ │ └── sign-up/ │ │ └── page.tsx │ ├── api/ │ │ ├── stripe/ │ │ │ ├── checkout/ │ │ │ │ └── route.ts │ │ │ └── webhook/ │ │ │ └── route.ts │ │ ├── team/ │ │ │ └── route.ts │ │ └── user/ │ │ └── route.ts │ ├── globals.css │ ├── layout.tsx │ └── not-found.tsx ├── components/ │ └── ui/ │ ├── avatar.tsx │ ├── button.tsx │ ├── card.tsx │ ├── dropdown-menu.tsx │ ├── input.tsx │ ├── label.tsx │ └── radio-group.tsx ├── components.json ├── drizzle.config.ts ├── lib/ │ ├── auth/ │ │ ├── middleware.ts │ │ └── session.ts │ ├── db/ │ │ ├── drizzle.ts │ │ ├── migrations/ │ │ │ ├── 0000_soft_the_anarchist.sql │ │ │ └── meta/ │ │ │ ├── 0000_snapshot.json │ │ │ └── _journal.json │ │ ├── queries.ts │ │ ├── schema.ts │ │ ├── seed.ts │ │ └── setup.ts │ ├── payments/ │ │ ├── actions.ts │ │ └── stripe.ts │ └── utils.ts ├── middleware.ts ├── next.config.ts ├── package.json ├── postcss.config.mjs └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js .yarn/install-state.gz # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* # local env files .env # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts .vscode # Docker postgres_data/ .env*.local ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2025 Vercel Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Next.js SaaS Starter This is a starter template for building a SaaS application using **Next.js** with support for authentication, Stripe integration for payments, and a dashboard for logged-in users. **Demo: [https://next-saas-start.vercel.app/](https://next-saas-start.vercel.app/)** ## Features - Marketing landing page (`/`) with animated Terminal element - Pricing page (`/pricing`) which connects to Stripe Checkout - Dashboard pages with CRUD operations on users/teams - Basic RBAC with Owner and Member roles - Subscription management with Stripe Customer Portal - Email/password authentication with JWTs stored to cookies - Global middleware to protect logged-in routes - Local middleware to protect Server Actions or validate Zod schemas - Activity logging system for any user events ## Tech Stack - **Framework**: [Next.js](https://nextjs.org/) - **Database**: [Postgres](https://www.postgresql.org/) - **ORM**: [Drizzle](https://orm.drizzle.team/) - **Payments**: [Stripe](https://stripe.com/) - **UI Library**: [shadcn/ui](https://ui.shadcn.com/) ## Getting Started ```bash git clone https://github.com/nextjs/saas-starter cd saas-starter pnpm install ``` ## Running Locally [Install](https://docs.stripe.com/stripe-cli) and log in to your Stripe account: ```bash stripe login ``` Use the included setup script to create your `.env` file: ```bash pnpm db:setup ``` Run the database migrations and seed the database with a default user and team: ```bash pnpm db:migrate pnpm db:seed ``` This will create the following user and team: - User: `test@test.com` - Password: `admin123` You can also create new users through the `/sign-up` route. Finally, run the Next.js development server: ```bash pnpm dev ``` Open [http://localhost:3000](http://localhost:3000) in your browser to see the app in action. You can listen for Stripe webhooks locally through their CLI to handle subscription change events: ```bash stripe listen --forward-to localhost:3000/api/stripe/webhook ``` ## Testing Payments To test Stripe payments, use the following test card details: - Card Number: `4242 4242 4242 4242` - Expiration: Any future date - CVC: Any 3-digit number ## Going to Production When you're ready to deploy your SaaS application to production, follow these steps: ### Set up a production Stripe webhook 1. Go to the Stripe Dashboard and create a new webhook for your production environment. 2. Set the endpoint URL to your production API route (e.g., `https://yourdomain.com/api/stripe/webhook`). 3. Select the events you want to listen for (e.g., `checkout.session.completed`, `customer.subscription.updated`). ### Deploy to Vercel 1. Push your code to a GitHub repository. 2. Connect your repository to [Vercel](https://vercel.com/) and deploy it. 3. Follow the Vercel deployment process, which will guide you through setting up your project. ### Add environment variables In your Vercel project settings (or during deployment), add all the necessary environment variables. Make sure to update the values for the production environment, including: 1. `BASE_URL`: Set this to your production domain. 2. `STRIPE_SECRET_KEY`: Use your Stripe secret key for the production environment. 3. `STRIPE_WEBHOOK_SECRET`: Use the webhook secret from the production webhook you created in step 1. 4. `POSTGRES_URL`: Set this to your production database URL. 5. `AUTH_SECRET`: Set this to a random string. `openssl rand -base64 32` will generate one. ## Other Templates While this template is intentionally minimal and to be used as a learning resource, there are other paid versions in the community which are more full-featured: - https://achromatic.dev - https://shipfa.st - https://makerkit.dev - https://zerotoshipped.com - https://turbostarter.dev ================================================ FILE: app/(dashboard)/dashboard/activity/loading.tsx ================================================ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; export default function ActivityPageSkeleton() { return (

Activity Log

Recent Activity
); } ================================================ FILE: app/(dashboard)/dashboard/activity/page.tsx ================================================ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Settings, LogOut, UserPlus, Lock, UserCog, AlertCircle, UserMinus, Mail, CheckCircle, type LucideIcon, } from 'lucide-react'; import { ActivityType } from '@/lib/db/schema'; import { getActivityLogs } from '@/lib/db/queries'; const iconMap: Record = { [ActivityType.SIGN_UP]: UserPlus, [ActivityType.SIGN_IN]: UserCog, [ActivityType.SIGN_OUT]: LogOut, [ActivityType.UPDATE_PASSWORD]: Lock, [ActivityType.DELETE_ACCOUNT]: UserMinus, [ActivityType.UPDATE_ACCOUNT]: Settings, [ActivityType.CREATE_TEAM]: UserPlus, [ActivityType.REMOVE_TEAM_MEMBER]: UserMinus, [ActivityType.INVITE_TEAM_MEMBER]: Mail, [ActivityType.ACCEPT_INVITATION]: CheckCircle, }; function getRelativeTime(date: Date) { const now = new Date(); const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000); if (diffInSeconds < 60) return 'just now'; if (diffInSeconds < 3600) return `${Math.floor(diffInSeconds / 60)} minutes ago`; if (diffInSeconds < 86400) return `${Math.floor(diffInSeconds / 3600)} hours ago`; if (diffInSeconds < 604800) return `${Math.floor(diffInSeconds / 86400)} days ago`; return date.toLocaleDateString(); } function formatAction(action: ActivityType): string { switch (action) { case ActivityType.SIGN_UP: return 'You signed up'; case ActivityType.SIGN_IN: return 'You signed in'; case ActivityType.SIGN_OUT: return 'You signed out'; case ActivityType.UPDATE_PASSWORD: return 'You changed your password'; case ActivityType.DELETE_ACCOUNT: return 'You deleted your account'; case ActivityType.UPDATE_ACCOUNT: return 'You updated your account'; case ActivityType.CREATE_TEAM: return 'You created a new team'; case ActivityType.REMOVE_TEAM_MEMBER: return 'You removed a team member'; case ActivityType.INVITE_TEAM_MEMBER: return 'You invited a team member'; case ActivityType.ACCEPT_INVITATION: return 'You accepted an invitation'; default: return 'Unknown action occurred'; } } export default async function ActivityPage() { const logs = await getActivityLogs(); return (

Activity Log

Recent Activity {logs.length > 0 ? (
    {logs.map((log) => { const Icon = iconMap[log.action as ActivityType] || Settings; const formattedAction = formatAction( log.action as ActivityType ); return (
  • {formattedAction} {log.ipAddress && ` from IP ${log.ipAddress}`}

    {getRelativeTime(new Date(log.timestamp))}

  • ); })}
) : (

No activity yet

When you perform actions like signing in or updating your account, they'll appear here.

)}
); } ================================================ FILE: app/(dashboard)/dashboard/general/page.tsx ================================================ 'use client'; import { useActionState } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { Loader2 } from 'lucide-react'; import { updateAccount } from '@/app/(login)/actions'; import { User } from '@/lib/db/schema'; import useSWR from 'swr'; import { Suspense } from 'react'; const fetcher = (url: string) => fetch(url).then((res) => res.json()); type ActionState = { name?: string; error?: string; success?: string; }; type AccountFormProps = { state: ActionState; nameValue?: string; emailValue?: string; }; function AccountForm({ state, nameValue = '', emailValue = '' }: AccountFormProps) { return ( <>
); } function AccountFormWithData({ state }: { state: ActionState }) { const { data: user } = useSWR('/api/user', fetcher); return ( ); } export default function GeneralPage() { const [state, formAction, isPending] = useActionState( updateAccount, {} ); return (

General Settings

Account Information
}> {state.error && (

{state.error}

)} {state.success && (

{state.success}

)}
); } ================================================ FILE: app/(dashboard)/dashboard/layout.tsx ================================================ 'use client'; import { useState } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { Button } from '@/components/ui/button'; import { Users, Settings, Shield, Activity, Menu } from 'lucide-react'; export default function DashboardLayout({ children }: { children: React.ReactNode; }) { const pathname = usePathname(); const [isSidebarOpen, setIsSidebarOpen] = useState(false); const navItems = [ { href: '/dashboard', icon: Users, label: 'Team' }, { href: '/dashboard/general', icon: Settings, label: 'General' }, { href: '/dashboard/activity', icon: Activity, label: 'Activity' }, { href: '/dashboard/security', icon: Shield, label: 'Security' } ]; return (
{/* Mobile header */}
Settings
{/* Sidebar */} {/* Main content */}
{children}
); } ================================================ FILE: app/(dashboard)/dashboard/page.tsx ================================================ 'use client'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Card, CardContent, CardHeader, CardTitle, CardFooter } from '@/components/ui/card'; import { customerPortalAction } from '@/lib/payments/actions'; import { useActionState } from 'react'; import { TeamDataWithMembers, User } from '@/lib/db/schema'; import { removeTeamMember, inviteTeamMember } from '@/app/(login)/actions'; import useSWR from 'swr'; import { Suspense } from 'react'; import { Input } from '@/components/ui/input'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Label } from '@/components/ui/label'; import { Loader2, PlusCircle } from 'lucide-react'; type ActionState = { error?: string; success?: string; }; const fetcher = (url: string) => fetch(url).then((res) => res.json()); function SubscriptionSkeleton() { return ( Team Subscription ); } function ManageSubscription() { const { data: teamData } = useSWR('/api/team', fetcher); return ( Team Subscription

Current Plan: {teamData?.planName || 'Free'}

{teamData?.subscriptionStatus === 'active' ? 'Billed monthly' : teamData?.subscriptionStatus === 'trialing' ? 'Trial period' : 'No active subscription'}

); } function TeamMembersSkeleton() { return ( Team Members
); } function TeamMembers() { const { data: teamData } = useSWR('/api/team', fetcher); const [removeState, removeAction, isRemovePending] = useActionState< ActionState, FormData >(removeTeamMember, {}); const getUserDisplayName = (user: Pick) => { return user.name || user.email || 'Unknown User'; }; if (!teamData?.teamMembers?.length) { return ( Team Members

No team members yet.

); } return ( Team Members
    {teamData.teamMembers.map((member, index) => (
  • {/* This app doesn't save profile images, but here is how you'd show them: */} {getUserDisplayName(member.user) .split(' ') .map((n) => n[0]) .join('')}

    {getUserDisplayName(member.user)}

    {member.role}

    {index > 1 ? (
    ) : null}
  • ))}
{removeState?.error && (

{removeState.error}

)}
); } function InviteTeamMemberSkeleton() { return ( Invite Team Member ); } function InviteTeamMember() { const { data: user } = useSWR('/api/user', fetcher); const isOwner = user?.role === 'owner'; const [inviteState, inviteAction, isInvitePending] = useActionState< ActionState, FormData >(inviteTeamMember, {}); return ( Invite Team Member
{inviteState?.error && (

{inviteState.error}

)} {inviteState?.success && (

{inviteState.success}

)}
{!isOwner && (

You must be a team owner to invite new members.

)}
); } export default function SettingsPage() { return (

Team Settings

}> }> }>
); } ================================================ FILE: app/(dashboard)/dashboard/security/page.tsx ================================================ 'use client'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; import { Lock, Trash2, Loader2 } from 'lucide-react'; import { useActionState } from 'react'; import { updatePassword, deleteAccount } from '@/app/(login)/actions'; type PasswordState = { currentPassword?: string; newPassword?: string; confirmPassword?: string; error?: string; success?: string; }; type DeleteState = { password?: string; error?: string; success?: string; }; export default function SecurityPage() { const [passwordState, passwordAction, isPasswordPending] = useActionState< PasswordState, FormData >(updatePassword, {}); const [deleteState, deleteAction, isDeletePending] = useActionState< DeleteState, FormData >(deleteAccount, {}); return (

Security Settings

Password
{passwordState.error && (

{passwordState.error}

)} {passwordState.success && (

{passwordState.success}

)}
Delete Account

Account deletion is non-reversable. Please proceed with caution.

{deleteState.error && (

{deleteState.error}

)}
); } ================================================ FILE: app/(dashboard)/layout.tsx ================================================ 'use client'; import Link from 'next/link'; import { use, useState, Suspense } from 'react'; import { Button } from '@/components/ui/button'; import { CircleIcon, Home, LogOut } from 'lucide-react'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { signOut } from '@/app/(login)/actions'; import { useRouter } from 'next/navigation'; import { User } from '@/lib/db/schema'; import useSWR, { mutate } from 'swr'; const fetcher = (url: string) => fetch(url).then((res) => res.json()); function UserMenu() { const [isMenuOpen, setIsMenuOpen] = useState(false); const { data: user } = useSWR('/api/user', fetcher); const router = useRouter(); async function handleSignOut() { await signOut(); mutate('/api/user'); router.push('/'); } if (!user) { return ( <> Pricing ); } return ( {user.email .split(' ') .map((n) => n[0]) .join('')} Dashboard
); } function Header() { return (
ACME
}>
); } export default function Layout({ children }: { children: React.ReactNode }) { return (
{children}
); } ================================================ FILE: app/(dashboard)/page.tsx ================================================ import { Button } from '@/components/ui/button'; import { ArrowRight, CreditCard, Database } from 'lucide-react'; import { Terminal } from './terminal'; export default function HomePage() { return (

Build Your SaaS Faster Than Ever

Launch your SaaS product in record time with our powerful, ready-to-use template. Packed with modern technologies and essential integrations.

Next.js and React

Leverage the power of modern web technologies for optimal performance and developer experience.

Postgres and Drizzle ORM

Robust database solution with an intuitive ORM for efficient data management and scalability.

Stripe Integration

Seamless payment processing and subscription management with industry-leading Stripe integration.

Ready to launch your SaaS?

Our template provides everything you need to get your SaaS up and running quickly. Don't waste time on boilerplate - focus on what makes your product unique.

); } ================================================ FILE: app/(dashboard)/pricing/page.tsx ================================================ import { checkoutAction } from '@/lib/payments/actions'; import { Check } from 'lucide-react'; import { getStripePrices, getStripeProducts } from '@/lib/payments/stripe'; import { SubmitButton } from './submit-button'; // Prices are fresh for one hour max export const revalidate = 3600; export default async function PricingPage() { const [prices, products] = await Promise.all([ getStripePrices(), getStripeProducts(), ]); const basePlan = products.find((product) => product.name === 'Base'); const plusPlan = products.find((product) => product.name === 'Plus'); const basePrice = prices.find((price) => price.productId === basePlan?.id); const plusPrice = prices.find((price) => price.productId === plusPlan?.id); return (
); } function PricingCard({ name, price, interval, trialDays, features, priceId, }: { name: string; price: number; interval: string; trialDays: number; features: string[]; priceId?: string; }) { return (

{name}

with {trialDays} day free trial

${price / 100}{' '} per user / {interval}

    {features.map((feature, index) => (
  • {feature}
  • ))}
); } ================================================ FILE: app/(dashboard)/pricing/submit-button.tsx ================================================ 'use client'; import { Button } from '@/components/ui/button'; import { ArrowRight, Loader2 } from 'lucide-react'; import { useFormStatus } from 'react-dom'; export function SubmitButton() { const { pending } = useFormStatus(); return ( ); } ================================================ FILE: app/(dashboard)/terminal.tsx ================================================ 'use client'; import { useState, useEffect } from 'react'; import { Copy, Check } from 'lucide-react'; export function Terminal() { const [terminalStep, setTerminalStep] = useState(0); const [copied, setCopied] = useState(false); const terminalSteps = [ 'git clone https://github.com/nextjs/saas-starter', 'pnpm install', 'pnpm db:setup', 'pnpm db:migrate', 'pnpm db:seed', 'pnpm dev 🎉', ]; useEffect(() => { const timer = setTimeout(() => { setTerminalStep((prev) => prev < terminalSteps.length - 1 ? prev + 1 : prev ); }, 500); return () => clearTimeout(timer); }, [terminalStep]); const copyToClipboard = () => { navigator.clipboard.writeText(terminalSteps.join('\n')); setCopied(true); setTimeout(() => setCopied(false), 2000); }; return (
{terminalSteps.map((step, index) => (
terminalStep ? 'opacity-0' : 'opacity-100'} transition-opacity duration-300`} > $ {step}
))}
); } ================================================ FILE: app/(login)/actions.ts ================================================ 'use server'; import { z } from 'zod'; import { and, eq, sql } from 'drizzle-orm'; import { db } from '@/lib/db/drizzle'; import { User, users, teams, teamMembers, activityLogs, type NewUser, type NewTeam, type NewTeamMember, type NewActivityLog, ActivityType, invitations } from '@/lib/db/schema'; import { comparePasswords, hashPassword, setSession } from '@/lib/auth/session'; import { redirect } from 'next/navigation'; import { cookies } from 'next/headers'; import { createCheckoutSession } from '@/lib/payments/stripe'; import { getUser, getUserWithTeam } from '@/lib/db/queries'; import { validatedAction, validatedActionWithUser } from '@/lib/auth/middleware'; async function logActivity( teamId: number | null | undefined, userId: number, type: ActivityType, ipAddress?: string ) { if (teamId === null || teamId === undefined) { return; } const newActivity: NewActivityLog = { teamId, userId, action: type, ipAddress: ipAddress || '' }; await db.insert(activityLogs).values(newActivity); } const signInSchema = z.object({ email: z.string().email().min(3).max(255), password: z.string().min(8).max(100) }); export const signIn = validatedAction(signInSchema, async (data, formData) => { const { email, password } = data; const userWithTeam = await db .select({ user: users, team: teams }) .from(users) .leftJoin(teamMembers, eq(users.id, teamMembers.userId)) .leftJoin(teams, eq(teamMembers.teamId, teams.id)) .where(eq(users.email, email)) .limit(1); if (userWithTeam.length === 0) { return { error: 'Invalid email or password. Please try again.', email, password }; } const { user: foundUser, team: foundTeam } = userWithTeam[0]; const isPasswordValid = await comparePasswords( password, foundUser.passwordHash ); if (!isPasswordValid) { return { error: 'Invalid email or password. Please try again.', email, password }; } await Promise.all([ setSession(foundUser), logActivity(foundTeam?.id, foundUser.id, ActivityType.SIGN_IN) ]); const redirectTo = formData.get('redirect') as string | null; if (redirectTo === 'checkout') { const priceId = formData.get('priceId') as string; return createCheckoutSession({ team: foundTeam, priceId }); } redirect('/dashboard'); }); const signUpSchema = z.object({ email: z.string().email(), password: z.string().min(8), inviteId: z.string().optional() }); export const signUp = validatedAction(signUpSchema, async (data, formData) => { const { email, password, inviteId } = data; const existingUser = await db .select() .from(users) .where(eq(users.email, email)) .limit(1); if (existingUser.length > 0) { return { error: 'Failed to create user. Please try again.', email, password }; } const passwordHash = await hashPassword(password); const newUser: NewUser = { email, passwordHash, role: 'owner' // Default role, will be overridden if there's an invitation }; const [createdUser] = await db.insert(users).values(newUser).returning(); if (!createdUser) { return { error: 'Failed to create user. Please try again.', email, password }; } let teamId: number; let userRole: string; let createdTeam: typeof teams.$inferSelect | null = null; if (inviteId) { // Check if there's a valid invitation const [invitation] = await db .select() .from(invitations) .where( and( eq(invitations.id, parseInt(inviteId)), eq(invitations.email, email), eq(invitations.status, 'pending') ) ) .limit(1); if (invitation) { teamId = invitation.teamId; userRole = invitation.role; await db .update(invitations) .set({ status: 'accepted' }) .where(eq(invitations.id, invitation.id)); await logActivity(teamId, createdUser.id, ActivityType.ACCEPT_INVITATION); [createdTeam] = await db .select() .from(teams) .where(eq(teams.id, teamId)) .limit(1); } else { return { error: 'Invalid or expired invitation.', email, password }; } } else { // Create a new team if there's no invitation const newTeam: NewTeam = { name: `${email}'s Team` }; [createdTeam] = await db.insert(teams).values(newTeam).returning(); if (!createdTeam) { return { error: 'Failed to create team. Please try again.', email, password }; } teamId = createdTeam.id; userRole = 'owner'; await logActivity(teamId, createdUser.id, ActivityType.CREATE_TEAM); } const newTeamMember: NewTeamMember = { userId: createdUser.id, teamId: teamId, role: userRole }; await Promise.all([ db.insert(teamMembers).values(newTeamMember), logActivity(teamId, createdUser.id, ActivityType.SIGN_UP), setSession(createdUser) ]); const redirectTo = formData.get('redirect') as string | null; if (redirectTo === 'checkout') { const priceId = formData.get('priceId') as string; return createCheckoutSession({ team: createdTeam, priceId }); } redirect('/dashboard'); }); export async function signOut() { const user = (await getUser()) as User; const userWithTeam = await getUserWithTeam(user.id); await logActivity(userWithTeam?.teamId, user.id, ActivityType.SIGN_OUT); (await cookies()).delete('session'); } const updatePasswordSchema = z.object({ currentPassword: z.string().min(8).max(100), newPassword: z.string().min(8).max(100), confirmPassword: z.string().min(8).max(100) }); export const updatePassword = validatedActionWithUser( updatePasswordSchema, async (data, _, user) => { const { currentPassword, newPassword, confirmPassword } = data; const isPasswordValid = await comparePasswords( currentPassword, user.passwordHash ); if (!isPasswordValid) { return { currentPassword, newPassword, confirmPassword, error: 'Current password is incorrect.' }; } if (currentPassword === newPassword) { return { currentPassword, newPassword, confirmPassword, error: 'New password must be different from the current password.' }; } if (confirmPassword !== newPassword) { return { currentPassword, newPassword, confirmPassword, error: 'New password and confirmation password do not match.' }; } const newPasswordHash = await hashPassword(newPassword); const userWithTeam = await getUserWithTeam(user.id); await Promise.all([ db .update(users) .set({ passwordHash: newPasswordHash }) .where(eq(users.id, user.id)), logActivity(userWithTeam?.teamId, user.id, ActivityType.UPDATE_PASSWORD) ]); return { success: 'Password updated successfully.' }; } ); const deleteAccountSchema = z.object({ password: z.string().min(8).max(100) }); export const deleteAccount = validatedActionWithUser( deleteAccountSchema, async (data, _, user) => { const { password } = data; const isPasswordValid = await comparePasswords(password, user.passwordHash); if (!isPasswordValid) { return { password, error: 'Incorrect password. Account deletion failed.' }; } const userWithTeam = await getUserWithTeam(user.id); await logActivity( userWithTeam?.teamId, user.id, ActivityType.DELETE_ACCOUNT ); // Soft delete await db .update(users) .set({ deletedAt: sql`CURRENT_TIMESTAMP`, email: sql`CONCAT(email, '-', id, '-deleted')` // Ensure email uniqueness }) .where(eq(users.id, user.id)); if (userWithTeam?.teamId) { await db .delete(teamMembers) .where( and( eq(teamMembers.userId, user.id), eq(teamMembers.teamId, userWithTeam.teamId) ) ); } (await cookies()).delete('session'); redirect('/sign-in'); } ); const updateAccountSchema = z.object({ name: z.string().min(1, 'Name is required').max(100), email: z.string().email('Invalid email address') }); export const updateAccount = validatedActionWithUser( updateAccountSchema, async (data, _, user) => { const { name, email } = data; const userWithTeam = await getUserWithTeam(user.id); await Promise.all([ db.update(users).set({ name, email }).where(eq(users.id, user.id)), logActivity(userWithTeam?.teamId, user.id, ActivityType.UPDATE_ACCOUNT) ]); return { name, success: 'Account updated successfully.' }; } ); const removeTeamMemberSchema = z.object({ memberId: z.number() }); export const removeTeamMember = validatedActionWithUser( removeTeamMemberSchema, async (data, _, user) => { const { memberId } = data; const userWithTeam = await getUserWithTeam(user.id); if (!userWithTeam?.teamId) { return { error: 'User is not part of a team' }; } await db .delete(teamMembers) .where( and( eq(teamMembers.id, memberId), eq(teamMembers.teamId, userWithTeam.teamId) ) ); await logActivity( userWithTeam.teamId, user.id, ActivityType.REMOVE_TEAM_MEMBER ); return { success: 'Team member removed successfully' }; } ); const inviteTeamMemberSchema = z.object({ email: z.string().email('Invalid email address'), role: z.enum(['member', 'owner']) }); export const inviteTeamMember = validatedActionWithUser( inviteTeamMemberSchema, async (data, _, user) => { const { email, role } = data; const userWithTeam = await getUserWithTeam(user.id); if (!userWithTeam?.teamId) { return { error: 'User is not part of a team' }; } const existingMember = await db .select() .from(users) .leftJoin(teamMembers, eq(users.id, teamMembers.userId)) .where( and(eq(users.email, email), eq(teamMembers.teamId, userWithTeam.teamId)) ) .limit(1); if (existingMember.length > 0) { return { error: 'User is already a member of this team' }; } // Check if there's an existing invitation const existingInvitation = await db .select() .from(invitations) .where( and( eq(invitations.email, email), eq(invitations.teamId, userWithTeam.teamId), eq(invitations.status, 'pending') ) ) .limit(1); if (existingInvitation.length > 0) { return { error: 'An invitation has already been sent to this email' }; } // Create a new invitation await db.insert(invitations).values({ teamId: userWithTeam.teamId, email, role, invitedBy: user.id, status: 'pending' }); await logActivity( userWithTeam.teamId, user.id, ActivityType.INVITE_TEAM_MEMBER ); // TODO: Send invitation email and include ?inviteId={id} to sign-up URL // await sendInvitationEmail(email, userWithTeam.team.name, role) return { success: 'Invitation sent successfully' }; } ); ================================================ FILE: app/(login)/login.tsx ================================================ 'use client'; import Link from 'next/link'; import { useActionState } from 'react'; import { useSearchParams } from 'next/navigation'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { CircleIcon, Loader2 } from 'lucide-react'; import { signIn, signUp } from './actions'; import { ActionState } from '@/lib/auth/middleware'; export function Login({ mode = 'signin' }: { mode?: 'signin' | 'signup' }) { const searchParams = useSearchParams(); const redirect = searchParams.get('redirect'); const priceId = searchParams.get('priceId'); const inviteId = searchParams.get('inviteId'); const [state, formAction, pending] = useActionState( mode === 'signin' ? signIn : signUp, { error: '' } ); return (

{mode === 'signin' ? 'Sign in to your account' : 'Create your account'}

{state?.error && (
{state.error}
)}
{mode === 'signin' ? 'New to our platform?' : 'Already have an account?'}
{mode === 'signin' ? 'Create an account' : 'Sign in to existing account'}
); } ================================================ FILE: app/(login)/sign-in/page.tsx ================================================ import { Suspense } from 'react'; import { Login } from '../login'; export default function SignInPage() { return ( ); } ================================================ FILE: app/(login)/sign-up/page.tsx ================================================ import { Suspense } from 'react'; import { Login } from '../login'; export default function SignUpPage() { return ( ); } ================================================ FILE: app/api/stripe/checkout/route.ts ================================================ import { eq } from 'drizzle-orm'; import { db } from '@/lib/db/drizzle'; import { users, teams, teamMembers } from '@/lib/db/schema'; import { setSession } from '@/lib/auth/session'; import { NextRequest, NextResponse } from 'next/server'; import { stripe } from '@/lib/payments/stripe'; import Stripe from 'stripe'; export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const sessionId = searchParams.get('session_id'); if (!sessionId) { return NextResponse.redirect(new URL('/pricing', request.url)); } try { const session = await stripe.checkout.sessions.retrieve(sessionId, { expand: ['customer', 'subscription'], }); if (!session.customer || typeof session.customer === 'string') { throw new Error('Invalid customer data from Stripe.'); } const customerId = session.customer.id; const subscriptionId = typeof session.subscription === 'string' ? session.subscription : session.subscription?.id; if (!subscriptionId) { throw new Error('No subscription found for this session.'); } const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ['items.data.price.product'], }); const plan = subscription.items.data[0]?.price; if (!plan) { throw new Error('No plan found for this subscription.'); } const productId = (plan.product as Stripe.Product).id; if (!productId) { throw new Error('No product ID found for this subscription.'); } const userId = session.client_reference_id; if (!userId) { throw new Error("No user ID found in session's client_reference_id."); } const user = await db .select() .from(users) .where(eq(users.id, Number(userId))) .limit(1); if (user.length === 0) { throw new Error('User not found in database.'); } const userTeam = await db .select({ teamId: teamMembers.teamId, }) .from(teamMembers) .where(eq(teamMembers.userId, user[0].id)) .limit(1); if (userTeam.length === 0) { throw new Error('User is not associated with any team.'); } await db .update(teams) .set({ stripeCustomerId: customerId, stripeSubscriptionId: subscriptionId, stripeProductId: productId, planName: (plan.product as Stripe.Product).name, subscriptionStatus: subscription.status, updatedAt: new Date(), }) .where(eq(teams.id, userTeam[0].teamId)); await setSession(user[0]); return NextResponse.redirect(new URL('/dashboard', request.url)); } catch (error) { console.error('Error handling successful checkout:', error); return NextResponse.redirect(new URL('/error', request.url)); } } ================================================ FILE: app/api/stripe/webhook/route.ts ================================================ import Stripe from 'stripe'; import { handleSubscriptionChange, stripe } from '@/lib/payments/stripe'; import { NextRequest, NextResponse } from 'next/server'; const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; export async function POST(request: NextRequest) { const payload = await request.text(); const signature = request.headers.get('stripe-signature') as string; let event: Stripe.Event; try { event = stripe.webhooks.constructEvent(payload, signature, webhookSecret); } catch (err) { console.error('Webhook signature verification failed.', err); return NextResponse.json( { error: 'Webhook signature verification failed.' }, { status: 400 } ); } switch (event.type) { case 'customer.subscription.updated': case 'customer.subscription.deleted': const subscription = event.data.object as Stripe.Subscription; await handleSubscriptionChange(subscription); break; default: console.log(`Unhandled event type ${event.type}`); } return NextResponse.json({ received: true }); } ================================================ FILE: app/api/team/route.ts ================================================ import { getTeamForUser } from '@/lib/db/queries'; export async function GET() { const team = await getTeamForUser(); return Response.json(team); } ================================================ FILE: app/api/user/route.ts ================================================ import { getUser } from '@/lib/db/queries'; export async function GET() { const user = await getUser(); return Response.json(user); } ================================================ FILE: app/globals.css ================================================ @import "tailwindcss"; /* ---break--- */ @custom-variant dark (&:is(.dark *)); @import "tw-animate-css"; @variant dark (&:is(.dark *)); @theme { --color-background: hsl(var(--background)); --color-foreground: hsl(var(--foreground)); --color-card: hsl(var(--card)); --color-card-foreground: hsl(var(--card-foreground)); --color-popover: hsl(var(--popover)); --color-popover-foreground: hsl(var(--popover-foreground)); --color-primary: hsl(var(--primary)); --color-primary-foreground: hsl(var(--primary-foreground)); --color-secondary: hsl(var(--secondary)); --color-secondary-foreground: hsl(var(--secondary-foreground)); --color-muted: hsl(var(--muted)); --color-muted-foreground: hsl(var(--muted-foreground)); --color-accent: hsl(var(--accent)); --color-accent-foreground: hsl(var(--accent-foreground)); --color-destructive: hsl(var(--destructive)); --color-destructive-foreground: hsl(var(--destructive-foreground)); --color-border: hsl(var(--border)); --color-input: hsl(var(--input)); --color-ring: hsl(var(--ring)); --color-chart-1: hsl(var(--chart-1)); --color-chart-2: hsl(var(--chart-2)); --color-chart-3: hsl(var(--chart-3)); --color-chart-4: hsl(var(--chart-4)); --color-chart-5: hsl(var(--chart-5)); --color-sidebar: hsl(var(--sidebar-background)); --color-sidebar-foreground: hsl(var(--sidebar-foreground)); --color-sidebar-primary: hsl(var(--sidebar-primary)); --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground)); --color-sidebar-accent: hsl(var(--sidebar-accent)); --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground)); --color-sidebar-border: hsl(var(--sidebar-border)); --color-sidebar-ring: hsl(var(--sidebar-ring)); --radius-lg: var(--radius); --radius-md: calc(var(--radius) - 2px); --radius-sm: calc(var(--radius) - 4px); } /* The default border color has changed to `currentColor` in Tailwind CSS v4, so we've added these compatibility styles to make sure everything still looks the same as it did with Tailwind CSS v3. If we ever want to remove these styles, we need to add an explicit border color utility to any element that depends on these defaults. */ @layer base { *, ::after, ::before, ::backdrop, ::file-selector-button { border-color: var(--color-gray-200, currentColor); } } @layer utilities { body { font-family: "Manrope", Arial, Helvetica, sans-serif; } } @layer base { :root { --background: 0 0% 100%; --foreground: 222.2 84% 4.9%; --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; --popover: 0 0% 100%; --popover-foreground: 222.2 84% 4.9%; --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; --secondary: 210 40% 96.1%; --secondary-foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; --chart-1: 12 76% 61%; --chart-2: 173 58% 39%; --chart-3: 197 37% 24%; --chart-4: 43 74% 66%; --chart-5: 27 87% 67%; --radius: 0.5rem; --sidebar-background: 0 0% 98%; --sidebar-foreground: 240 5.3% 26.1%; --sidebar-primary: 240 5.9% 10%; --sidebar-primary-foreground: 0 0% 98%; --sidebar-accent: 240 4.8% 95.9%; --sidebar-accent-foreground: 240 5.9% 10%; --sidebar-border: 220 13% 91%; --sidebar-ring: 217.2 91.2% 59.8%; } .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; --card: 222.2 84% 4.9%; --card-foreground: 210 40% 98%; --popover: 222.2 84% 4.9%; --popover-foreground: 210 40% 98%; --primary: 210 40% 98%; --primary-foreground: 222.2 47.4% 11.2%; --secondary: 217.2 32.6% 17.5%; --secondary-foreground: 210 40% 98%; --muted: 217.2 32.6% 17.5%; --muted-foreground: 215 20.2% 65.1%; --accent: 217.2 32.6% 17.5%; --accent-foreground: 210 40% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --ring: 212.7 26.8% 83.9%; --chart-1: 220 70% 50%; --chart-2: 160 60% 45%; --chart-3: 30 80% 55%; --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; --sidebar-background: 240 5.9% 10%; --sidebar-foreground: 240 4.8% 95.9%; --sidebar-primary: 224.3 76.3% 48%; --sidebar-primary-foreground: 0 0% 100%; --sidebar-accent: 240 3.7% 15.9%; --sidebar-accent-foreground: 240 4.8% 95.9%; --sidebar-border: 240 3.7% 15.9%; --sidebar-ring: 217.2 91.2% 59.8%; } } @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; } } /* ---break--- */ :root { --background: hsl(0 0% 100%); --foreground: hsl(240 10% 3.9%); --card: hsl(0 0% 100%); --card-foreground: hsl(240 10% 3.9%); --popover: hsl(0 0% 100%); --popover-foreground: hsl(240 10% 3.9%); --primary: hsl(240 5.9% 10%); --primary-foreground: hsl(0 0% 98%); --secondary: hsl(240 4.8% 95.9%); --secondary-foreground: hsl(240 5.9% 10%); --muted: hsl(240 4.8% 95.9%); --muted-foreground: hsl(240 3.8% 46.1%); --accent: hsl(240 4.8% 95.9%); --accent-foreground: hsl(240 5.9% 10%); --destructive: hsl(0 84.2% 60.2%); --destructive-foreground: hsl(0 0% 98%); --border: hsl(240 5.9% 90%); --input: hsl(240 5.9% 90%); --ring: hsl(240 10% 3.9%); --chart-1: hsl(12 76% 61%); --chart-2: hsl(173 58% 39%); --chart-3: hsl(197 37% 24%); --chart-4: hsl(43 74% 66%); --chart-5: hsl(27 87% 67%); --radius: 0.6rem; } /* ---break--- */ .dark { --background: hsl(240 10% 3.9%); --foreground: hsl(0 0% 98%); --card: hsl(240 10% 3.9%); --card-foreground: hsl(0 0% 98%); --popover: hsl(240 10% 3.9%); --popover-foreground: hsl(0 0% 98%); --primary: hsl(0 0% 98%); --primary-foreground: hsl(240 5.9% 10%); --secondary: hsl(240 3.7% 15.9%); --secondary-foreground: hsl(0 0% 98%); --muted: hsl(240 3.7% 15.9%); --muted-foreground: hsl(240 5% 64.9%); --accent: hsl(240 3.7% 15.9%); --accent-foreground: hsl(0 0% 98%); --destructive: hsl(0 62.8% 30.6%); --destructive-foreground: hsl(0 0% 98%); --border: hsl(240 3.7% 15.9%); --input: hsl(240 3.7% 15.9%); --ring: hsl(240 4.9% 83.9%); --chart-1: hsl(220 70% 50%); --chart-2: hsl(160 60% 45%); --chart-3: hsl(30 80% 55%); --chart-4: hsl(280 65% 60%); --chart-5: hsl(340 75% 55%); } /* ---break--- */ @theme inline { --color-background: var(--background); --color-foreground: var(--foreground); --color-card: var(--card); --color-card-foreground: var(--card-foreground); --color-popover: var(--popover); --color-popover-foreground: var(--popover-foreground); --color-primary: var(--primary); --color-primary-foreground: var(--primary-foreground); --color-secondary: var(--secondary); --color-secondary-foreground: var(--secondary-foreground); --color-muted: var(--muted); --color-muted-foreground: var(--muted-foreground); --color-accent: var(--accent); --color-accent-foreground: var(--accent-foreground); --color-destructive: var(--destructive); --color-destructive-foreground: var(--destructive-foreground); --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); --color-chart-1: var(--chart-1); --color-chart-2: var(--chart-2); --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); --radius-xl: calc(var(--radius) + 4px); } /* ---break--- */ @layer base { * { @apply border-border outline-ring/50; } body { @apply bg-background text-foreground; } } ================================================ FILE: app/layout.tsx ================================================ import './globals.css'; import type { Metadata, Viewport } from 'next'; import { Manrope } from 'next/font/google'; import { getUser, getTeamForUser } from '@/lib/db/queries'; import { SWRConfig } from 'swr'; export const metadata: Metadata = { title: 'Next.js SaaS Starter', description: 'Get started quickly with Next.js, Postgres, and Stripe.' }; export const viewport: Viewport = { maximumScale: 1 }; const manrope = Manrope({ subsets: ['latin'] }); export default function RootLayout({ children }: { children: React.ReactNode; }) { return ( {children} ); } ================================================ FILE: app/not-found.tsx ================================================ import Link from 'next/link'; import { CircleIcon } from 'lucide-react'; export default function NotFound() { return (

Page Not Found

The page you are looking for might have been removed, had its name changed, or is temporarily unavailable.

Back to Home
); } ================================================ FILE: components/ui/avatar.tsx ================================================ "use client"; import * as React from "react"; import { Avatar as AvatarPrimitive } from "radix-ui";; import { cn } from "@/lib/utils"; function Avatar({ className, ...props }: React.ComponentProps) { return ( ); } function AvatarImage({ className, ...props }: React.ComponentProps) { return ( ); } function AvatarFallback({ className, ...props }: React.ComponentProps) { return ( ); } export { Avatar, AvatarImage, AvatarFallback }; ================================================ FILE: components/ui/button.tsx ================================================ import * as React from "react"; import { Slot as SlotPrimitive } from "radix-ui";; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", destructive: "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", link: "text-primary underline-offset-4 hover:underline" }, size: { default: "h-9 px-4 py-2 has-[>svg]:px-3", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", lg: "h-10 rounded-md px-6 has-[>svg]:px-4", icon: "size-9" } }, defaultVariants: { variant: "default", size: "default" } } ); function Button({ className, variant, size, asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps & { asChild?: boolean; }) { const Comp = asChild ? SlotPrimitive.Slot : "button"; return ( ); } export { Button, buttonVariants }; ================================================ FILE: components/ui/card.tsx ================================================ import * as React from "react"; import { cn } from "@/lib/utils"; function Card({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardHeader({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardTitle({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardDescription({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardAction({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardContent({ className, ...props }: React.ComponentProps<"div">) { return (
); } function CardFooter({ className, ...props }: React.ComponentProps<"div">) { return (
); } export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent }; ================================================ FILE: components/ui/dropdown-menu.tsx ================================================ "use client"; import * as React from "react"; import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui";; import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; import { cn } from "@/lib/utils"; function DropdownMenu({ ...props }: React.ComponentProps) { return ; } function DropdownMenuPortal({ ...props }: React.ComponentProps) { return ( ); } function DropdownMenuTrigger({ ...props }: React.ComponentProps) { return ( ); } function DropdownMenuContent({ className, sideOffset = 4, ...props }: React.ComponentProps) { return ( ); } function DropdownMenuGroup({ ...props }: React.ComponentProps) { return ( ); } function DropdownMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps & { inset?: boolean; variant?: "default" | "destructive"; }) { return ( ); } function DropdownMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps) { return ( {children} ); } function DropdownMenuRadioGroup({ ...props }: React.ComponentProps) { return ( ); } function DropdownMenuRadioItem({ className, children, ...props }: React.ComponentProps) { return ( {children} ); } function DropdownMenuLabel({ className, inset, ...props }: React.ComponentProps & { inset?: boolean; }) { return ( ); } function DropdownMenuSeparator({ className, ...props }: React.ComponentProps) { return ( ); } function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { return ( ); } function DropdownMenuSub({ ...props }: React.ComponentProps) { return ; } function DropdownMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps & { inset?: boolean; }) { return ( {children} ); } function DropdownMenuSubContent({ className, ...props }: React.ComponentProps) { return ( ); } export { DropdownMenu, DropdownMenuPortal, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGroup, DropdownMenuLabel, DropdownMenuItem, DropdownMenuCheckboxItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent }; ================================================ FILE: components/ui/input.tsx ================================================ import * as React from "react"; import { cn } from "@/lib/utils"; function Input({ className, type, ...props }: React.ComponentProps<"input">) { return ( ); } export { Input }; ================================================ FILE: components/ui/label.tsx ================================================ "use client"; import * as React from "react"; import { Label as LabelPrimitive } from "radix-ui";; import { cn } from "@/lib/utils"; function Label({ className, ...props }: React.ComponentProps) { return ( ); } export { Label }; ================================================ FILE: components/ui/radio-group.tsx ================================================ "use client"; import * as React from "react"; import { RadioGroup as RadioGroupPrimitive } from "radix-ui";; import { CircleIcon } from "lucide-react"; import { cn } from "@/lib/utils"; function RadioGroup({ className, ...props }: React.ComponentProps) { return ( ); } function RadioGroupItem({ className, ...props }: React.ComponentProps) { return ( ); } export { RadioGroup, RadioGroupItem }; ================================================ FILE: components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": true, "tsx": true, "tailwind": { "config": "", "css": "app/globals.css", "baseColor": "zinc", "cssVariables": true, "prefix": "" }, "aliases": { "components": "@/components", "utils": "@/lib/utils", "ui": "@/components/ui", "lib": "@/lib", "hooks": "@/hooks" }, "iconLibrary": "lucide" } ================================================ FILE: drizzle.config.ts ================================================ import type { Config } from 'drizzle-kit'; export default { schema: './lib/db/schema.ts', out: './lib/db/migrations', dialect: 'postgresql', dbCredentials: { url: process.env.POSTGRES_URL!, }, } satisfies Config; ================================================ FILE: lib/auth/middleware.ts ================================================ import { z } from 'zod'; import { TeamDataWithMembers, User } from '@/lib/db/schema'; import { getTeamForUser, getUser } from '@/lib/db/queries'; import { redirect } from 'next/navigation'; export type ActionState = { error?: string; success?: string; [key: string]: any; // This allows for additional properties }; type ValidatedActionFunction, T> = ( data: z.infer, formData: FormData ) => Promise; export function validatedAction, T>( schema: S, action: ValidatedActionFunction ) { return async (prevState: ActionState, formData: FormData) => { const result = schema.safeParse(Object.fromEntries(formData)); if (!result.success) { return { error: result.error.errors[0].message }; } return action(result.data, formData); }; } type ValidatedActionWithUserFunction, T> = ( data: z.infer, formData: FormData, user: User ) => Promise; export function validatedActionWithUser, T>( schema: S, action: ValidatedActionWithUserFunction ) { return async (prevState: ActionState, formData: FormData) => { const user = await getUser(); if (!user) { throw new Error('User is not authenticated'); } const result = schema.safeParse(Object.fromEntries(formData)); if (!result.success) { return { error: result.error.errors[0].message }; } return action(result.data, formData, user); }; } type ActionWithTeamFunction = ( formData: FormData, team: TeamDataWithMembers ) => Promise; export function withTeam(action: ActionWithTeamFunction) { return async (formData: FormData): Promise => { const user = await getUser(); if (!user) { redirect('/sign-in'); } const team = await getTeamForUser(); if (!team) { throw new Error('Team not found'); } return action(formData, team); }; } ================================================ FILE: lib/auth/session.ts ================================================ import { compare, hash } from 'bcryptjs'; import { SignJWT, jwtVerify } from 'jose'; import { cookies } from 'next/headers'; import { NewUser } from '@/lib/db/schema'; const key = new TextEncoder().encode(process.env.AUTH_SECRET); const SALT_ROUNDS = 10; export async function hashPassword(password: string) { return hash(password, SALT_ROUNDS); } export async function comparePasswords( plainTextPassword: string, hashedPassword: string ) { return compare(plainTextPassword, hashedPassword); } type SessionData = { user: { id: number }; expires: string; }; export async function signToken(payload: SessionData) { return await new SignJWT(payload) .setProtectedHeader({ alg: 'HS256' }) .setIssuedAt() .setExpirationTime('1 day from now') .sign(key); } export async function verifyToken(input: string) { const { payload } = await jwtVerify(input, key, { algorithms: ['HS256'], }); return payload as SessionData; } export async function getSession() { const session = (await cookies()).get('session')?.value; if (!session) return null; return await verifyToken(session); } export async function setSession(user: NewUser) { const expiresInOneDay = new Date(Date.now() + 24 * 60 * 60 * 1000); const session: SessionData = { user: { id: user.id! }, expires: expiresInOneDay.toISOString(), }; const encryptedSession = await signToken(session); (await cookies()).set('session', encryptedSession, { expires: expiresInOneDay, httpOnly: true, secure: true, sameSite: 'lax', }); } ================================================ FILE: lib/db/drizzle.ts ================================================ import { drizzle } from 'drizzle-orm/postgres-js'; import postgres from 'postgres'; import * as schema from './schema'; import dotenv from 'dotenv'; dotenv.config(); if (!process.env.POSTGRES_URL) { throw new Error('POSTGRES_URL environment variable is not set'); } export const client = postgres(process.env.POSTGRES_URL); export const db = drizzle(client, { schema }); ================================================ FILE: lib/db/migrations/0000_soft_the_anarchist.sql ================================================ CREATE TABLE IF NOT EXISTS "activity_logs" ( "id" serial PRIMARY KEY NOT NULL, "team_id" integer NOT NULL, "user_id" integer, "action" text NOT NULL, "timestamp" timestamp DEFAULT now() NOT NULL, "ip_address" varchar(45) ); --> statement-breakpoint CREATE TABLE IF NOT EXISTS "invitations" ( "id" serial PRIMARY KEY NOT NULL, "team_id" integer NOT NULL, "email" varchar(255) NOT NULL, "role" varchar(50) NOT NULL, "invited_by" integer NOT NULL, "invited_at" timestamp DEFAULT now() NOT NULL, "status" varchar(20) DEFAULT 'pending' NOT NULL ); --> statement-breakpoint CREATE TABLE IF NOT EXISTS "team_members" ( "id" serial PRIMARY KEY NOT NULL, "user_id" integer NOT NULL, "team_id" integer NOT NULL, "role" varchar(50) NOT NULL, "joined_at" timestamp DEFAULT now() NOT NULL ); --> statement-breakpoint CREATE TABLE IF NOT EXISTS "teams" ( "id" serial PRIMARY KEY NOT NULL, "name" varchar(100) NOT NULL, "created_at" timestamp DEFAULT now() NOT NULL, "updated_at" timestamp DEFAULT now() NOT NULL, "stripe_customer_id" text, "stripe_subscription_id" text, "stripe_product_id" text, "plan_name" varchar(50), "subscription_status" varchar(20), CONSTRAINT "teams_stripe_customer_id_unique" UNIQUE("stripe_customer_id"), CONSTRAINT "teams_stripe_subscription_id_unique" UNIQUE("stripe_subscription_id") ); --> statement-breakpoint CREATE TABLE IF NOT EXISTS "users" ( "id" serial PRIMARY KEY NOT NULL, "name" varchar(100), "email" varchar(255) NOT NULL, "password_hash" text NOT NULL, "role" varchar(20) DEFAULT 'member' NOT NULL, "created_at" timestamp DEFAULT now() NOT NULL, "updated_at" timestamp DEFAULT now() NOT NULL, "deleted_at" timestamp, CONSTRAINT "users_email_unique" UNIQUE("email") ); --> statement-breakpoint DO $$ BEGIN ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN ALTER TABLE "activity_logs" ADD CONSTRAINT "activity_logs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN ALTER TABLE "invitations" ADD CONSTRAINT "invitations_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN ALTER TABLE "invitations" ADD CONSTRAINT "invitations_invited_by_users_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN ALTER TABLE "team_members" ADD CONSTRAINT "team_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; --> statement-breakpoint DO $$ BEGIN ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE no action ON UPDATE no action; EXCEPTION WHEN duplicate_object THEN null; END $$; ================================================ FILE: lib/db/migrations/meta/0000_snapshot.json ================================================ { "id": "261fd993-fb2c-43e7-89d6-cd58786c5f58", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", "tables": { "public.activity_logs": { "name": "activity_logs", "schema": "", "columns": { "id": { "name": "id", "type": "serial", "primaryKey": true, "notNull": true }, "team_id": { "name": "team_id", "type": "integer", "primaryKey": false, "notNull": true }, "user_id": { "name": "user_id", "type": "integer", "primaryKey": false, "notNull": false }, "action": { "name": "action", "type": "text", "primaryKey": false, "notNull": true }, "timestamp": { "name": "timestamp", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "ip_address": { "name": "ip_address", "type": "varchar(45)", "primaryKey": false, "notNull": false } }, "indexes": {}, "foreignKeys": { "activity_logs_team_id_teams_id_fk": { "name": "activity_logs_team_id_teams_id_fk", "tableFrom": "activity_logs", "tableTo": "teams", "columnsFrom": [ "team_id" ], "columnsTo": [ "id" ], "onDelete": "no action", "onUpdate": "no action" }, "activity_logs_user_id_users_id_fk": { "name": "activity_logs_user_id_users_id_fk", "tableFrom": "activity_logs", "tableTo": "users", "columnsFrom": [ "user_id" ], "columnsTo": [ "id" ], "onDelete": "no action", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {} }, "public.invitations": { "name": "invitations", "schema": "", "columns": { "id": { "name": "id", "type": "serial", "primaryKey": true, "notNull": true }, "team_id": { "name": "team_id", "type": "integer", "primaryKey": false, "notNull": true }, "email": { "name": "email", "type": "varchar(255)", "primaryKey": false, "notNull": true }, "role": { "name": "role", "type": "varchar(50)", "primaryKey": false, "notNull": true }, "invited_by": { "name": "invited_by", "type": "integer", "primaryKey": false, "notNull": true }, "invited_at": { "name": "invited_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "status": { "name": "status", "type": "varchar(20)", "primaryKey": false, "notNull": true, "default": "'pending'" } }, "indexes": {}, "foreignKeys": { "invitations_team_id_teams_id_fk": { "name": "invitations_team_id_teams_id_fk", "tableFrom": "invitations", "tableTo": "teams", "columnsFrom": [ "team_id" ], "columnsTo": [ "id" ], "onDelete": "no action", "onUpdate": "no action" }, "invitations_invited_by_users_id_fk": { "name": "invitations_invited_by_users_id_fk", "tableFrom": "invitations", "tableTo": "users", "columnsFrom": [ "invited_by" ], "columnsTo": [ "id" ], "onDelete": "no action", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {} }, "public.team_members": { "name": "team_members", "schema": "", "columns": { "id": { "name": "id", "type": "serial", "primaryKey": true, "notNull": true }, "user_id": { "name": "user_id", "type": "integer", "primaryKey": false, "notNull": true }, "team_id": { "name": "team_id", "type": "integer", "primaryKey": false, "notNull": true }, "role": { "name": "role", "type": "varchar(50)", "primaryKey": false, "notNull": true }, "joined_at": { "name": "joined_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" } }, "indexes": {}, "foreignKeys": { "team_members_user_id_users_id_fk": { "name": "team_members_user_id_users_id_fk", "tableFrom": "team_members", "tableTo": "users", "columnsFrom": [ "user_id" ], "columnsTo": [ "id" ], "onDelete": "no action", "onUpdate": "no action" }, "team_members_team_id_teams_id_fk": { "name": "team_members_team_id_teams_id_fk", "tableFrom": "team_members", "tableTo": "teams", "columnsFrom": [ "team_id" ], "columnsTo": [ "id" ], "onDelete": "no action", "onUpdate": "no action" } }, "compositePrimaryKeys": {}, "uniqueConstraints": {} }, "public.teams": { "name": "teams", "schema": "", "columns": { "id": { "name": "id", "type": "serial", "primaryKey": true, "notNull": true }, "name": { "name": "name", "type": "varchar(100)", "primaryKey": false, "notNull": true }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "stripe_customer_id": { "name": "stripe_customer_id", "type": "text", "primaryKey": false, "notNull": false }, "stripe_subscription_id": { "name": "stripe_subscription_id", "type": "text", "primaryKey": false, "notNull": false }, "stripe_product_id": { "name": "stripe_product_id", "type": "text", "primaryKey": false, "notNull": false }, "plan_name": { "name": "plan_name", "type": "varchar(50)", "primaryKey": false, "notNull": false }, "subscription_status": { "name": "subscription_status", "type": "varchar(20)", "primaryKey": false, "notNull": false } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": { "teams_stripe_customer_id_unique": { "name": "teams_stripe_customer_id_unique", "nullsNotDistinct": false, "columns": [ "stripe_customer_id" ] }, "teams_stripe_subscription_id_unique": { "name": "teams_stripe_subscription_id_unique", "nullsNotDistinct": false, "columns": [ "stripe_subscription_id" ] } } }, "public.users": { "name": "users", "schema": "", "columns": { "id": { "name": "id", "type": "serial", "primaryKey": true, "notNull": true }, "name": { "name": "name", "type": "varchar(100)", "primaryKey": false, "notNull": false }, "email": { "name": "email", "type": "varchar(255)", "primaryKey": false, "notNull": true }, "password_hash": { "name": "password_hash", "type": "text", "primaryKey": false, "notNull": true }, "role": { "name": "role", "type": "varchar(20)", "primaryKey": false, "notNull": true, "default": "'member'" }, "created_at": { "name": "created_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "updated_at": { "name": "updated_at", "type": "timestamp", "primaryKey": false, "notNull": true, "default": "now()" }, "deleted_at": { "name": "deleted_at", "type": "timestamp", "primaryKey": false, "notNull": false } }, "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": { "users_email_unique": { "name": "users_email_unique", "nullsNotDistinct": false, "columns": [ "email" ] } } } }, "enums": {}, "schemas": {}, "sequences": {}, "_meta": { "columns": {}, "schemas": {}, "tables": {} } } ================================================ FILE: lib/db/migrations/meta/_journal.json ================================================ { "version": "7", "dialect": "postgresql", "entries": [ { "idx": 0, "version": "7", "when": 1726443359662, "tag": "0000_soft_the_anarchist", "breakpoints": true } ] } ================================================ FILE: lib/db/queries.ts ================================================ import { desc, and, eq, isNull } from 'drizzle-orm'; import { db } from './drizzle'; import { activityLogs, teamMembers, teams, users } from './schema'; import { cookies } from 'next/headers'; import { verifyToken } from '@/lib/auth/session'; export async function getUser() { const sessionCookie = (await cookies()).get('session'); if (!sessionCookie || !sessionCookie.value) { return null; } const sessionData = await verifyToken(sessionCookie.value); if ( !sessionData || !sessionData.user || typeof sessionData.user.id !== 'number' ) { return null; } if (new Date(sessionData.expires) < new Date()) { return null; } const user = await db .select() .from(users) .where(and(eq(users.id, sessionData.user.id), isNull(users.deletedAt))) .limit(1); if (user.length === 0) { return null; } return user[0]; } export async function getTeamByStripeCustomerId(customerId: string) { const result = await db .select() .from(teams) .where(eq(teams.stripeCustomerId, customerId)) .limit(1); return result.length > 0 ? result[0] : null; } export async function updateTeamSubscription( teamId: number, subscriptionData: { stripeSubscriptionId: string | null; stripeProductId: string | null; planName: string | null; subscriptionStatus: string; } ) { await db .update(teams) .set({ ...subscriptionData, updatedAt: new Date() }) .where(eq(teams.id, teamId)); } export async function getUserWithTeam(userId: number) { const result = await db .select({ user: users, teamId: teamMembers.teamId }) .from(users) .leftJoin(teamMembers, eq(users.id, teamMembers.userId)) .where(eq(users.id, userId)) .limit(1); return result[0]; } export async function getActivityLogs() { const user = await getUser(); if (!user) { throw new Error('User not authenticated'); } return await db .select({ id: activityLogs.id, action: activityLogs.action, timestamp: activityLogs.timestamp, ipAddress: activityLogs.ipAddress, userName: users.name }) .from(activityLogs) .leftJoin(users, eq(activityLogs.userId, users.id)) .where(eq(activityLogs.userId, user.id)) .orderBy(desc(activityLogs.timestamp)) .limit(10); } export async function getTeamForUser() { const user = await getUser(); if (!user) { return null; } const result = await db.query.teamMembers.findFirst({ where: eq(teamMembers.userId, user.id), with: { team: { with: { teamMembers: { with: { user: { columns: { id: true, name: true, email: true } } } } } } } }); return result?.team || null; } ================================================ FILE: lib/db/schema.ts ================================================ import { pgTable, serial, varchar, text, timestamp, integer, } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; export const users = pgTable('users', { id: serial('id').primaryKey(), name: varchar('name', { length: 100 }), email: varchar('email', { length: 255 }).notNull().unique(), passwordHash: text('password_hash').notNull(), role: varchar('role', { length: 20 }).notNull().default('member'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), deletedAt: timestamp('deleted_at'), }); export const teams = pgTable('teams', { id: serial('id').primaryKey(), name: varchar('name', { length: 100 }).notNull(), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), stripeCustomerId: text('stripe_customer_id').unique(), stripeSubscriptionId: text('stripe_subscription_id').unique(), stripeProductId: text('stripe_product_id'), planName: varchar('plan_name', { length: 50 }), subscriptionStatus: varchar('subscription_status', { length: 20 }), }); export const teamMembers = pgTable('team_members', { id: serial('id').primaryKey(), userId: integer('user_id') .notNull() .references(() => users.id), teamId: integer('team_id') .notNull() .references(() => teams.id), role: varchar('role', { length: 50 }).notNull(), joinedAt: timestamp('joined_at').notNull().defaultNow(), }); export const activityLogs = pgTable('activity_logs', { id: serial('id').primaryKey(), teamId: integer('team_id') .notNull() .references(() => teams.id), userId: integer('user_id').references(() => users.id), action: text('action').notNull(), timestamp: timestamp('timestamp').notNull().defaultNow(), ipAddress: varchar('ip_address', { length: 45 }), }); export const invitations = pgTable('invitations', { id: serial('id').primaryKey(), teamId: integer('team_id') .notNull() .references(() => teams.id), email: varchar('email', { length: 255 }).notNull(), role: varchar('role', { length: 50 }).notNull(), invitedBy: integer('invited_by') .notNull() .references(() => users.id), invitedAt: timestamp('invited_at').notNull().defaultNow(), status: varchar('status', { length: 20 }).notNull().default('pending'), }); export const teamsRelations = relations(teams, ({ many }) => ({ teamMembers: many(teamMembers), activityLogs: many(activityLogs), invitations: many(invitations), })); export const usersRelations = relations(users, ({ many }) => ({ teamMembers: many(teamMembers), invitationsSent: many(invitations), })); export const invitationsRelations = relations(invitations, ({ one }) => ({ team: one(teams, { fields: [invitations.teamId], references: [teams.id], }), invitedBy: one(users, { fields: [invitations.invitedBy], references: [users.id], }), })); export const teamMembersRelations = relations(teamMembers, ({ one }) => ({ user: one(users, { fields: [teamMembers.userId], references: [users.id], }), team: one(teams, { fields: [teamMembers.teamId], references: [teams.id], }), })); export const activityLogsRelations = relations(activityLogs, ({ one }) => ({ team: one(teams, { fields: [activityLogs.teamId], references: [teams.id], }), user: one(users, { fields: [activityLogs.userId], references: [users.id], }), })); export type User = typeof users.$inferSelect; export type NewUser = typeof users.$inferInsert; export type Team = typeof teams.$inferSelect; export type NewTeam = typeof teams.$inferInsert; export type TeamMember = typeof teamMembers.$inferSelect; export type NewTeamMember = typeof teamMembers.$inferInsert; export type ActivityLog = typeof activityLogs.$inferSelect; export type NewActivityLog = typeof activityLogs.$inferInsert; export type Invitation = typeof invitations.$inferSelect; export type NewInvitation = typeof invitations.$inferInsert; export type TeamDataWithMembers = Team & { teamMembers: (TeamMember & { user: Pick; })[]; }; export enum ActivityType { SIGN_UP = 'SIGN_UP', SIGN_IN = 'SIGN_IN', SIGN_OUT = 'SIGN_OUT', UPDATE_PASSWORD = 'UPDATE_PASSWORD', DELETE_ACCOUNT = 'DELETE_ACCOUNT', UPDATE_ACCOUNT = 'UPDATE_ACCOUNT', CREATE_TEAM = 'CREATE_TEAM', REMOVE_TEAM_MEMBER = 'REMOVE_TEAM_MEMBER', INVITE_TEAM_MEMBER = 'INVITE_TEAM_MEMBER', ACCEPT_INVITATION = 'ACCEPT_INVITATION', } ================================================ FILE: lib/db/seed.ts ================================================ import { stripe } from '../payments/stripe'; import { db } from './drizzle'; import { users, teams, teamMembers } from './schema'; import { hashPassword } from '@/lib/auth/session'; async function createStripeProducts() { console.log('Creating Stripe products and prices...'); const baseProduct = await stripe.products.create({ name: 'Base', description: 'Base subscription plan', }); await stripe.prices.create({ product: baseProduct.id, unit_amount: 800, // $8 in cents currency: 'usd', recurring: { interval: 'month', trial_period_days: 7, }, }); const plusProduct = await stripe.products.create({ name: 'Plus', description: 'Plus subscription plan', }); await stripe.prices.create({ product: plusProduct.id, unit_amount: 1200, // $12 in cents currency: 'usd', recurring: { interval: 'month', trial_period_days: 7, }, }); console.log('Stripe products and prices created successfully.'); } async function seed() { const email = 'test@test.com'; const password = 'admin123'; const passwordHash = await hashPassword(password); const [user] = await db .insert(users) .values([ { email: email, passwordHash: passwordHash, role: "owner", }, ]) .returning(); console.log('Initial user created.'); const [team] = await db .insert(teams) .values({ name: 'Test Team', }) .returning(); await db.insert(teamMembers).values({ teamId: team.id, userId: user.id, role: 'owner', }); await createStripeProducts(); } seed() .catch((error) => { console.error('Seed process failed:', error); process.exit(1); }) .finally(() => { console.log('Seed process finished. Exiting...'); process.exit(0); }); ================================================ FILE: lib/db/setup.ts ================================================ import { exec } from 'node:child_process'; import { promises as fs } from 'node:fs'; import { promisify } from 'node:util'; import readline from 'node:readline'; import crypto from 'node:crypto'; import path from 'node:path'; import os from 'node:os'; const execAsync = promisify(exec); function question(query: string): Promise { const rl = readline.createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve) => rl.question(query, (ans) => { rl.close(); resolve(ans); }) ); } async function checkStripeCLI() { console.log( 'Step 1: Checking if Stripe CLI is installed and authenticated...' ); try { await execAsync('stripe --version'); console.log('Stripe CLI is installed.'); // Check if Stripe CLI is authenticated try { await execAsync('stripe config --list'); console.log('Stripe CLI is authenticated.'); } catch (error) { console.log( 'Stripe CLI is not authenticated or the authentication has expired.' ); console.log('Please run: stripe login'); const answer = await question( 'Have you completed the authentication? (y/n): ' ); if (answer.toLowerCase() !== 'y') { console.log( 'Please authenticate with Stripe CLI and run this script again.' ); process.exit(1); } // Verify authentication after user confirms login try { await execAsync('stripe config --list'); console.log('Stripe CLI authentication confirmed.'); } catch (error) { console.error( 'Failed to verify Stripe CLI authentication. Please try again.' ); process.exit(1); } } } catch (error) { console.error( 'Stripe CLI is not installed. Please install it and try again.' ); console.log('To install Stripe CLI, follow these steps:'); console.log('1. Visit: https://docs.stripe.com/stripe-cli'); console.log( '2. Download and install the Stripe CLI for your operating system' ); console.log('3. After installation, run: stripe login'); console.log( 'After installation and authentication, please run this setup script again.' ); process.exit(1); } } async function getPostgresURL(): Promise { console.log('Step 2: Setting up Postgres'); const dbChoice = await question( 'Do you want to use a local Postgres instance with Docker (L) or a remote Postgres instance (R)? (L/R): ' ); if (dbChoice.toLowerCase() === 'l') { console.log('Setting up local Postgres instance with Docker...'); await setupLocalPostgres(); return 'postgres://postgres:postgres@localhost:54322/postgres'; } else { console.log( 'You can find Postgres databases at: https://vercel.com/marketplace?category=databases' ); return await question('Enter your POSTGRES_URL: '); } } async function setupLocalPostgres() { console.log('Checking if Docker is installed...'); try { await execAsync('docker --version'); console.log('Docker is installed.'); } catch (error) { console.error( 'Docker is not installed. Please install Docker and try again.' ); console.log( 'To install Docker, visit: https://docs.docker.com/get-docker/' ); process.exit(1); } console.log('Creating docker-compose.yml file...'); const dockerComposeContent = ` services: postgres: image: postgres:16.4-alpine container_name: next_saas_starter_postgres environment: POSTGRES_DB: postgres POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres ports: - "54322:5432" volumes: - postgres_data:/var/lib/postgresql/data volumes: postgres_data: `; await fs.writeFile( path.join(process.cwd(), 'docker-compose.yml'), dockerComposeContent ); console.log('docker-compose.yml file created.'); console.log('Starting Docker container with `docker compose up -d`...'); try { await execAsync('docker compose up -d'); console.log('Docker container started successfully.'); } catch (error) { console.error( 'Failed to start Docker container. Please check your Docker installation and try again.' ); process.exit(1); } } async function getStripeSecretKey(): Promise { console.log('Step 3: Getting Stripe Secret Key'); console.log( 'You can find your Stripe Secret Key at: https://dashboard.stripe.com/test/apikeys' ); return await question('Enter your Stripe Secret Key: '); } async function createStripeWebhook(): Promise { console.log('Step 4: Creating Stripe webhook...'); try { const { stdout } = await execAsync('stripe listen --print-secret'); const match = stdout.match(/whsec_[a-zA-Z0-9]+/); if (!match) { throw new Error('Failed to extract Stripe webhook secret'); } console.log('Stripe webhook created.'); return match[0]; } catch (error) { console.error( 'Failed to create Stripe webhook. Check your Stripe CLI installation and permissions.' ); if (os.platform() === 'win32') { console.log( 'Note: On Windows, you may need to run this script as an administrator.' ); } throw error; } } function generateAuthSecret(): string { console.log('Step 5: Generating AUTH_SECRET...'); return crypto.randomBytes(32).toString('hex'); } async function writeEnvFile(envVars: Record) { console.log('Step 6: Writing environment variables to .env'); const envContent = Object.entries(envVars) .map(([key, value]) => `${key}=${value}`) .join('\n'); await fs.writeFile(path.join(process.cwd(), '.env'), envContent); console.log('.env file created with the necessary variables.'); } async function main() { await checkStripeCLI(); const POSTGRES_URL = await getPostgresURL(); const STRIPE_SECRET_KEY = await getStripeSecretKey(); const STRIPE_WEBHOOK_SECRET = await createStripeWebhook(); const BASE_URL = 'http://localhost:3000'; const AUTH_SECRET = generateAuthSecret(); await writeEnvFile({ POSTGRES_URL, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, BASE_URL, AUTH_SECRET, }); console.log('🎉 Setup completed successfully!'); } main().catch(console.error); ================================================ FILE: lib/payments/actions.ts ================================================ 'use server'; import { redirect } from 'next/navigation'; import { createCheckoutSession, createCustomerPortalSession } from './stripe'; import { withTeam } from '@/lib/auth/middleware'; export const checkoutAction = withTeam(async (formData, team) => { const priceId = formData.get('priceId') as string; await createCheckoutSession({ team: team, priceId }); }); export const customerPortalAction = withTeam(async (_, team) => { const portalSession = await createCustomerPortalSession(team); redirect(portalSession.url); }); ================================================ FILE: lib/payments/stripe.ts ================================================ import Stripe from 'stripe'; import { redirect } from 'next/navigation'; import { Team } from '@/lib/db/schema'; import { getTeamByStripeCustomerId, getUser, updateTeamSubscription } from '@/lib/db/queries'; export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2025-04-30.basil' }); export async function createCheckoutSession({ team, priceId }: { team: Team | null; priceId: string; }) { const user = await getUser(); if (!team || !user) { redirect(`/sign-up?redirect=checkout&priceId=${priceId}`); } const session = await stripe.checkout.sessions.create({ payment_method_types: ['card'], line_items: [ { price: priceId, quantity: 1 } ], mode: 'subscription', success_url: `${process.env.BASE_URL}/api/stripe/checkout?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${process.env.BASE_URL}/pricing`, customer: team.stripeCustomerId || undefined, client_reference_id: user.id.toString(), allow_promotion_codes: true, subscription_data: { trial_period_days: 14 } }); redirect(session.url!); } export async function createCustomerPortalSession(team: Team) { if (!team.stripeCustomerId || !team.stripeProductId) { redirect('/pricing'); } let configuration: Stripe.BillingPortal.Configuration; const configurations = await stripe.billingPortal.configurations.list(); if (configurations.data.length > 0) { configuration = configurations.data[0]; } else { const product = await stripe.products.retrieve(team.stripeProductId); if (!product.active) { throw new Error("Team's product is not active in Stripe"); } const prices = await stripe.prices.list({ product: product.id, active: true }); if (prices.data.length === 0) { throw new Error("No active prices found for the team's product"); } configuration = await stripe.billingPortal.configurations.create({ business_profile: { headline: 'Manage your subscription' }, features: { subscription_update: { enabled: true, default_allowed_updates: ['price', 'quantity', 'promotion_code'], proration_behavior: 'create_prorations', products: [ { product: product.id, prices: prices.data.map((price) => price.id) } ] }, subscription_cancel: { enabled: true, mode: 'at_period_end', cancellation_reason: { enabled: true, options: [ 'too_expensive', 'missing_features', 'switched_service', 'unused', 'other' ] } }, payment_method_update: { enabled: true } } }); } return stripe.billingPortal.sessions.create({ customer: team.stripeCustomerId, return_url: `${process.env.BASE_URL}/dashboard`, configuration: configuration.id }); } export async function handleSubscriptionChange( subscription: Stripe.Subscription ) { const customerId = subscription.customer as string; const subscriptionId = subscription.id; const status = subscription.status; const team = await getTeamByStripeCustomerId(customerId); if (!team) { console.error('Team not found for Stripe customer:', customerId); return; } if (status === 'active' || status === 'trialing') { const plan = subscription.items.data[0]?.plan; await updateTeamSubscription(team.id, { stripeSubscriptionId: subscriptionId, stripeProductId: plan?.product as string, planName: (plan?.product as Stripe.Product).name, subscriptionStatus: status }); } else if (status === 'canceled' || status === 'unpaid') { await updateTeamSubscription(team.id, { stripeSubscriptionId: null, stripeProductId: null, planName: null, subscriptionStatus: status }); } } export async function getStripePrices() { const prices = await stripe.prices.list({ expand: ['data.product'], active: true, type: 'recurring' }); return prices.data.map((price) => ({ id: price.id, productId: typeof price.product === 'string' ? price.product : price.product.id, unitAmount: price.unit_amount, currency: price.currency, interval: price.recurring?.interval, trialPeriodDays: price.recurring?.trial_period_days })); } export async function getStripeProducts() { const products = await stripe.products.list({ active: true, expand: ['data.default_price'] }); return products.data.map((product) => ({ id: product.id, name: product.name, description: product.description, defaultPriceId: typeof product.default_price === 'string' ? product.default_price : product.default_price?.id })); } ================================================ FILE: lib/utils.ts ================================================ import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } ================================================ FILE: middleware.ts ================================================ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { signToken, verifyToken } from '@/lib/auth/session'; const protectedRoutes = '/dashboard'; export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; const sessionCookie = request.cookies.get('session'); const isProtectedRoute = pathname.startsWith(protectedRoutes); if (isProtectedRoute && !sessionCookie) { return NextResponse.redirect(new URL('/sign-in', request.url)); } let res = NextResponse.next(); if (sessionCookie && request.method === 'GET') { try { const parsed = await verifyToken(sessionCookie.value); const expiresInOneDay = new Date(Date.now() + 24 * 60 * 60 * 1000); res.cookies.set({ name: 'session', value: await signToken({ ...parsed, expires: expiresInOneDay.toISOString() }), httpOnly: true, secure: true, sameSite: 'lax', expires: expiresInOneDay }); } catch (error) { console.error('Error updating session:', error); res.cookies.delete('session'); if (isProtectedRoute) { return NextResponse.redirect(new URL('/sign-in', request.url)); } } } return res; } export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'], runtime: 'nodejs' }; ================================================ FILE: next.config.ts ================================================ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { experimental: { ppr: true, clientSegmentCache: true } }; export default nextConfig; ================================================ FILE: package.json ================================================ { "private": true, "scripts": { "dev": "next dev --turbopack", "build": "next build", "start": "next start", "db:setup": "npx tsx lib/db/setup.ts", "db:seed": "npx tsx lib/db/seed.ts", "db:generate": "drizzle-kit generate", "db:migrate": "drizzle-kit migrate", "db:studio": "drizzle-kit studio" }, "dependencies": { "@tailwindcss/postcss": "4.1.7", "@types/node": "^22.15.18", "@types/react": "19.1.4", "@types/react-dom": "19.1.5", "autoprefixer": "^10.4.21", "bcryptjs": "^3.0.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "dotenv": "^16.5.0", "drizzle-kit": "^0.31.1", "drizzle-orm": "^0.43.1", "jose": "^6.0.11", "lucide-react": "^0.511.0", "next": "15.6.0-canary.59", "postcss": "^8.5.3", "postgres": "^3.4.5", "radix-ui": "^1.4.2", "react": "19.1.0", "react-dom": "19.1.0", "server-only": "^0.0.1", "stripe": "^18.1.0", "swr": "^2.3.3", "tailwind-merge": "^3.3.0", "tailwindcss": "4.1.7", "tw-animate-css": "^1.3.0", "typescript": "^5.8.3", "zod": "^3.24.4" } } ================================================ FILE: postcss.config.mjs ================================================ export default { plugins: { '@tailwindcss/postcss': {}, }, }; ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "ESNext", "lib": [ "dom", "dom.iterable", "esnext" ], "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "baseUrl": ".", "plugins": [ { "name": "next" } ], "paths": { "@/*": [ "./*" ] } }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts" ], "exclude": [ "node_modules" ] }