├── .env.example ├── .eslintrc.json ├── app ├── favicon.ico ├── (authenticated) │ ├── layout.tsx │ ├── subscribe │ │ └── page.tsx │ ├── dashboard │ │ └── page.tsx │ └── admin │ │ └── dashboard │ │ └── page.tsx ├── layout.tsx ├── error │ └── page.tsx ├── api │ ├── todos │ │ ├── [id] │ │ │ └── route.ts │ │ └── route.ts │ ├── subscription │ │ └── route.ts │ ├── webhook │ │ └── register │ │ │ └── route.ts │ └── admin │ │ ├── route.ts │ │ └── todos │ │ └── route.ts ├── globals.css ├── sign-in │ └── page.tsx ├── sign-up │ └── page.tsx └── page.tsx ├── prisma ├── migrations │ ├── 20240917062946_init │ │ └── migration.sql │ ├── migration_lock.toml │ ├── 20240916062612_init │ │ └── migration.sql │ ├── 20240916035542_init │ │ └── migration.sql │ └── 20240917081932_init │ │ └── migration.sql └── schema.prisma ├── .env.local.example ├── types └── globals.d.ts ├── postcss.config.mjs ├── next.config.mjs ├── lib ├── utils.ts └── prisma.ts ├── components ├── BackButton.tsx ├── ui │ ├── label.tsx │ ├── input.tsx │ ├── toaster.tsx │ ├── avatar.tsx │ ├── alert.tsx │ ├── button.tsx │ ├── card.tsx │ ├── toast.tsx │ └── dropdown-menu.tsx ├── Pagination.tsx ├── TodoForm.tsx ├── TodoItem.tsx └── Navbar.tsx ├── components.json ├── .gitignore ├── tsconfig.json ├── package.json ├── tailwind.config.ts ├── middleware.ts ├── README.md └── hooks └── use-toast.ts /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="your-database_url" -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hiteshchoudhary/saas-clerk-template/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /prisma/migrations/20240917062946_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "subscriptionEnds" TIMESTAMP(3); 3 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /.env.local.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your-next_public_clerk_publishable_key 2 | CLERK_SECRET_KEY=your-clerk_secret_key 3 | WEBHOOK_SECRET=your-webhook_secret 4 | -------------------------------------------------------------------------------- /types/globals.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | 3 | declare global { 4 | interface CustomJwtSessionClaims { 5 | metadata: { 6 | role?: "admin"; 7 | }; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | domains: ["img.clerk.com"], 5 | }, 6 | }; 7 | 8 | export default nextConfig; 9 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /app/(authenticated)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Navbar from "@/components/Navbar"; 2 | 3 | export default function AppLayout({ children }: { children: React.ReactNode }) { 4 | return ( 5 | <> 6 | 7 | {children} 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /prisma/migrations/20240916062612_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL, 4 | "isSubscribed" BOOLEAN NOT NULL DEFAULT false, 5 | 6 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 7 | ); 8 | 9 | -- AddForeignKey 10 | ALTER TABLE "Todo" ADD CONSTRAINT "Todo_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 11 | -------------------------------------------------------------------------------- /prisma/migrations/20240916035542_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "Todo" ( 3 | "id" TEXT NOT NULL, 4 | "title" TEXT NOT NULL, 5 | "completed" BOOLEAN NOT NULL DEFAULT false, 6 | "userId" TEXT NOT NULL, 7 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 8 | "updatedAt" TIMESTAMP(3) NOT NULL, 9 | 10 | CONSTRAINT "Todo_pkey" PRIMARY KEY ("id") 11 | ); 12 | -------------------------------------------------------------------------------- /components/BackButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { ChevronLeft } from "lucide-react"; 5 | import { useRouter } from "next/navigation"; 6 | 7 | export function BackButton() { 8 | const router = useRouter(); 9 | 10 | return ( 11 | 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /prisma/migrations/20240917081932_init/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[email]` on the table `User` will be added. If there are existing duplicate values, this will fail. 5 | - Added the required column `email` to the `User` table without a default value. This is not possible if the table is not empty. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE "User" ADD COLUMN "email" TEXT NOT NULL; 10 | 11 | -- CreateIndex 12 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 13 | -------------------------------------------------------------------------------- /lib/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | const prismaClientSingleton = () => { 4 | return new PrismaClient(); 5 | }; 6 | 7 | type PrismaClientSingleton = ReturnType; 8 | 9 | const globalForPrisma = globalThis as unknown as { 10 | prisma: PrismaClientSingleton | undefined; 11 | }; 12 | 13 | const prisma = globalForPrisma.prisma ?? prismaClientSingleton(); 14 | 15 | export default prisma; 16 | 17 | if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma; 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | } 9 | 10 | model User { 11 | id String @id 12 | email String @unique 13 | isSubscribed Boolean @default(false) 14 | subscriptionEnds DateTime? 15 | todos Todo[] 16 | } 17 | 18 | model Todo { 19 | id String @id @default(cuid()) 20 | title String 21 | completed Boolean @default(false) 22 | user User @relation(fields: [userId], references: [id]) 23 | userId String 24 | createdAt DateTime @default(now()) 25 | updatedAt DateTime @updatedAt 26 | } -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import "./globals.css"; 3 | import { ClerkProvider } from "@clerk/nextjs"; 4 | import { Inter } from "next/font/google"; 5 | import { Toaster } from "@/components/ui/toaster"; 6 | 7 | const inter = Inter({ subsets: ["latin"] }); 8 | 9 | export const metadata: Metadata = { 10 | title: "Role Based Auth Clerk", 11 | description: "role based auth app using prisma and clerk", 12 | }; 13 | 14 | export default function RootLayout({ 15 | children, 16 | }: { 17 | children: React.ReactNode; 18 | }) { 19 | return ( 20 | 21 | 22 | 23 | {children} 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useToast } from "@/hooks/use-toast" 4 | import { 5 | Toast, 6 | ToastClose, 7 | ToastDescription, 8 | ToastProvider, 9 | ToastTitle, 10 | ToastViewport, 11 | } from "@/components/ui/toast" 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast() 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ) 31 | })} 32 | 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /components/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button } from "@/components/ui/button"; 3 | 4 | interface PaginationProps { 5 | currentPage: number; 6 | totalPages: number; 7 | onPageChange: (page: number) => void; 8 | } 9 | 10 | export function Pagination({ 11 | currentPage, 12 | totalPages, 13 | onPageChange, 14 | }: PaginationProps) { 15 | return ( 16 |
17 | 24 | 25 | Page {currentPage} of {totalPages} 26 | 27 | 34 |
35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /components/TodoForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { Input } from "@/components/ui/input"; 5 | import { Button } from "@/components/ui/button"; 6 | 7 | interface TodoFormProps { 8 | onSubmit: (title: string) => void; 9 | } 10 | 11 | export function TodoForm({ onSubmit }: TodoFormProps) { 12 | const [title, setTitle] = useState(""); 13 | 14 | const handleSubmit = (e: React.FormEvent) => { 15 | e.preventDefault(); 16 | if (title.trim()) { 17 | onSubmit(title.trim()); 18 | setTitle(""); 19 | } 20 | }; 21 | 22 | return ( 23 |
24 | setTitle(e.target.value)} 28 | placeholder="Enter a new todo" 29 | className="flex-grow" 30 | required 31 | /> 32 | 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "role-based-auth", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "postinstall": "prisma generate" 11 | }, 12 | "dependencies": { 13 | "@clerk/nextjs": "^5.7.1", 14 | "@dicebear/collection": "^9.2.2", 15 | "@dicebear/core": "^9.2.2", 16 | "@prisma/client": "^5.19.1", 17 | "@radix-ui/react-avatar": "^1.1.0", 18 | "@radix-ui/react-dropdown-menu": "^2.1.1", 19 | "@radix-ui/react-icons": "^1.3.0", 20 | "@radix-ui/react-label": "^2.1.0", 21 | "@radix-ui/react-slot": "^1.1.0", 22 | "@radix-ui/react-toast": "^1.2.1", 23 | "class-variance-authority": "^0.7.0", 24 | "clsx": "^2.1.1", 25 | "lucide-react": "^0.441.0", 26 | "next": "14.2.11", 27 | "react": "^18", 28 | "react-dom": "^18", 29 | "svix": "^1.34.0", 30 | "tailwind-merge": "^2.5.2", 31 | "tailwindcss-animate": "^1.0.7", 32 | "usehooks-ts": "^3.1.0" 33 | }, 34 | "devDependencies": { 35 | "@types/node": "^20", 36 | "@types/react": "^18", 37 | "@types/react-dom": "^18", 38 | "eslint": "^8", 39 | "eslint-config-next": "14.2.11", 40 | "postcss": "^8", 41 | "prisma": "^5.19.1", 42 | "tailwindcss": "^3.4.1", 43 | "typescript": "^5" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /app/error/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | import { AlertTriangle } from "lucide-react"; 6 | import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; 7 | import { Button } from "@/components/ui/button"; 8 | 9 | export default function ErrorPage() { 10 | const router = useRouter(); 11 | 12 | useEffect(() => { 13 | const timer = setTimeout(() => { 14 | router.push("/"); 15 | }, 5000); 16 | 17 | return () => clearTimeout(timer); 18 | }, [router]); 19 | 20 | return ( 21 |
22 | 23 | 24 | 25 | 26 | Oops! Something went wrong 27 | 28 | 29 | 30 |

31 | We encountered an unexpected error. Don't worry, we're 32 | working on fixing it. 33 |

34 |

35 | You'll be redirected to the home page in 5 seconds, or you can 36 | click the button below. 37 |

38 | 41 |
42 |
43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | theme: { 11 | extend: { 12 | colors: { 13 | background: 'hsl(var(--background))', 14 | foreground: 'hsl(var(--foreground))', 15 | card: { 16 | DEFAULT: 'hsl(var(--card))', 17 | foreground: 'hsl(var(--card-foreground))' 18 | }, 19 | popover: { 20 | DEFAULT: 'hsl(var(--popover))', 21 | foreground: 'hsl(var(--popover-foreground))' 22 | }, 23 | primary: { 24 | DEFAULT: 'hsl(var(--primary))', 25 | foreground: 'hsl(var(--primary-foreground))' 26 | }, 27 | secondary: { 28 | DEFAULT: 'hsl(var(--secondary))', 29 | foreground: 'hsl(var(--secondary-foreground))' 30 | }, 31 | muted: { 32 | DEFAULT: 'hsl(var(--muted))', 33 | foreground: 'hsl(var(--muted-foreground))' 34 | }, 35 | accent: { 36 | DEFAULT: 'hsl(var(--accent))', 37 | foreground: 'hsl(var(--accent-foreground))' 38 | }, 39 | destructive: { 40 | DEFAULT: 'hsl(var(--destructive))', 41 | foreground: 'hsl(var(--destructive-foreground))' 42 | }, 43 | border: 'hsl(var(--border))', 44 | input: 'hsl(var(--input))', 45 | ring: 'hsl(var(--ring))', 46 | chart: { 47 | '1': 'hsl(var(--chart-1))', 48 | '2': 'hsl(var(--chart-2))', 49 | '3': 'hsl(var(--chart-3))', 50 | '4': 'hsl(var(--chart-4))', 51 | '5': 'hsl(var(--chart-5))' 52 | } 53 | }, 54 | borderRadius: { 55 | lg: 'var(--radius)', 56 | md: 'calc(var(--radius) - 2px)', 57 | sm: 'calc(var(--radius) - 4px)' 58 | } 59 | } 60 | }, 61 | plugins: [require("tailwindcss-animate")], 62 | }; 63 | export default config; 64 | -------------------------------------------------------------------------------- /components/TodoItem.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import { Todo } from "@prisma/client"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Trash2, CheckCircle, XCircle } from "lucide-react"; 7 | import { Card, CardContent } from "@/components/ui/card"; 8 | 9 | interface TodoItemProps { 10 | todo: Todo; 11 | isAdmin?: boolean; 12 | onUpdate: (id: string, completed: boolean) => void; 13 | onDelete: (id: string) => void; 14 | } 15 | 16 | export function TodoItem({ 17 | todo, 18 | isAdmin = false, 19 | onUpdate, 20 | onDelete, 21 | }: TodoItemProps) { 22 | const [isCompleted, setIsCompleted] = useState(todo.completed); 23 | 24 | const toggleComplete = async () => { 25 | const newCompletedState = !isCompleted; 26 | setIsCompleted(newCompletedState); 27 | onUpdate(todo.id, newCompletedState); 28 | }; 29 | 30 | return ( 31 | 32 | 33 | {todo.title} 34 |
35 | 43 | 51 | {isAdmin && ( 52 | 53 | User ID: {todo.userId} 54 | 55 | )} 56 |
57 |
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { authMiddleware, clerkClient } from "@clerk/nextjs/server"; 2 | import { NextResponse } from "next/server"; 3 | 4 | const publicRoutes = ["/", "/api/webhook/register", "/sign-in", "/sign-up"]; 5 | 6 | /** 7 | * @deprecated Use `newFunction()` instead. 8 | */ 9 | export default authMiddleware({ 10 | publicRoutes, 11 | async afterAuth(auth, req) { 12 | // Use 'auth' for authentication details and 'req' for NextRequest 13 | // Handle unauthenticated users trying to access protected routes 14 | if (!auth.userId && !publicRoutes.includes(req.nextUrl.pathname)) { 15 | return NextResponse.redirect(new URL("/sign-in", req.url)); 16 | } 17 | 18 | if (auth.userId) { 19 | try { 20 | const user = await clerkClient.users.getUser(auth.userId); // Fetch user data from Clerk 21 | const role = user.publicMetadata.role as string | undefined; 22 | 23 | // Admin role redirection logic 24 | if (role === "admin" && req.nextUrl.pathname === "/dashboard") { 25 | return NextResponse.redirect(new URL("/admin/dashboard", req.url)); 26 | } 27 | 28 | // Prevent non-admin users from accessing admin routes 29 | if (role !== "admin" && req.nextUrl.pathname.startsWith("/admin")) { 30 | return NextResponse.redirect(new URL("/dashboard", req.url)); 31 | } 32 | 33 | // Redirect authenticated users trying to access public routes 34 | if (publicRoutes.includes(req.nextUrl.pathname)) { 35 | return NextResponse.redirect( 36 | new URL( 37 | role === "admin" ? "/admin/dashboard" : "/dashboard", 38 | req.url 39 | ) 40 | ); 41 | } 42 | } catch (error) { 43 | console.error("Error fetching user data from Clerk:", error); 44 | return NextResponse.redirect(new URL("/error", req.url)); 45 | } 46 | } 47 | }, 48 | }); 49 | 50 | export const config = { 51 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"], 52 | }; 53 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /app/api/todos/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { auth } from "@clerk/nextjs/server"; 3 | import prisma from "@/lib/prisma"; 4 | 5 | export async function PUT( 6 | req: NextRequest, 7 | { params }: { params: { id: string } } 8 | ) { 9 | const { userId } = auth(); 10 | 11 | if (!userId) { 12 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 13 | } 14 | 15 | try { 16 | const { completed } = await req.json(); 17 | const todoId = params.id; 18 | 19 | const todo = await prisma.todo.findUnique({ 20 | where: { id: todoId }, 21 | }); 22 | 23 | if (!todo) { 24 | return NextResponse.json({ error: "Todo not found" }, { status: 404 }); 25 | } 26 | 27 | if (todo.userId !== userId) { 28 | return NextResponse.json({ error: "Forbidden" }, { status: 403 }); 29 | } 30 | 31 | const updatedTodo = await prisma.todo.update({ 32 | where: { id: todoId }, 33 | data: { completed }, 34 | }); 35 | 36 | return NextResponse.json(updatedTodo); 37 | } catch (error) { 38 | return NextResponse.json( 39 | { error: "Internal Server Error" }, 40 | { status: 500 } 41 | ); 42 | } 43 | } 44 | 45 | export async function DELETE( 46 | req: NextRequest, 47 | { params }: { params: { id: string } } 48 | ) { 49 | const { userId } = auth(); 50 | 51 | if (!userId) { 52 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 53 | } 54 | 55 | try { 56 | const todoId = params.id; 57 | 58 | const todo = await prisma.todo.findUnique({ 59 | where: { id: todoId }, 60 | }); 61 | 62 | if (!todo) { 63 | return NextResponse.json({ error: "Todo not found" }, { status: 404 }); 64 | } 65 | 66 | if (todo.userId !== userId) { 67 | return NextResponse.json({ error: "Forbidden" }, { status: 403 }); 68 | } 69 | 70 | await prisma.todo.delete({ 71 | where: { id: todoId }, 72 | }); 73 | 74 | return NextResponse.json({ message: "Todo deleted successfully" }); 75 | } catch (error) { 76 | return NextResponse.json( 77 | { error: "Internal Server Error" }, 78 | { status: 500 } 79 | ); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --background: #ffffff; 7 | --foreground: #171717; 8 | } 9 | 10 | /* @media (prefers-color-scheme: dark) { 11 | :root { 12 | --background: #0a0a0a; 13 | --foreground: #ededed; 14 | } 15 | } */ 16 | 17 | body { 18 | color: var(--foreground); 19 | background: var(--background); 20 | font-family: Arial, Helvetica, sans-serif; 21 | } 22 | 23 | @layer utilities { 24 | .text-balance { 25 | text-wrap: balance; 26 | } 27 | } 28 | 29 | @layer base { 30 | :root { 31 | --background: 0 0% 100%; 32 | --foreground: 0 0% 3.9%; 33 | --card: 0 0% 100%; 34 | --card-foreground: 0 0% 3.9%; 35 | --popover: 0 0% 100%; 36 | --popover-foreground: 0 0% 3.9%; 37 | --primary: 0 0% 9%; 38 | --primary-foreground: 0 0% 98%; 39 | --secondary: 0 0% 96.1%; 40 | --secondary-foreground: 0 0% 9%; 41 | --muted: 0 0% 96.1%; 42 | --muted-foreground: 0 0% 45.1%; 43 | --accent: 0 0% 96.1%; 44 | --accent-foreground: 0 0% 9%; 45 | --destructive: 0 84.2% 60.2%; 46 | --destructive-foreground: 0 0% 98%; 47 | --border: 0 0% 89.8%; 48 | --input: 0 0% 89.8%; 49 | --ring: 0 0% 3.9%; 50 | --chart-1: 12 76% 61%; 51 | --chart-2: 173 58% 39%; 52 | --chart-3: 197 37% 24%; 53 | --chart-4: 43 74% 66%; 54 | --chart-5: 27 87% 67%; 55 | --radius: 0.5rem; 56 | } 57 | .dark { 58 | --background: 0 0% 3.9%; 59 | --foreground: 0 0% 98%; 60 | --card: 0 0% 3.9%; 61 | --card-foreground: 0 0% 98%; 62 | --popover: 0 0% 3.9%; 63 | --popover-foreground: 0 0% 98%; 64 | --primary: 0 0% 98%; 65 | --primary-foreground: 0 0% 9%; 66 | --secondary: 0 0% 14.9%; 67 | --secondary-foreground: 0 0% 98%; 68 | --muted: 0 0% 14.9%; 69 | --muted-foreground: 0 0% 63.9%; 70 | --accent: 0 0% 14.9%; 71 | --accent-foreground: 0 0% 98%; 72 | --destructive: 0 62.8% 30.6%; 73 | --destructive-foreground: 0 0% 98%; 74 | --border: 0 0% 14.9%; 75 | --input: 0 0% 14.9%; 76 | --ring: 0 0% 83.1%; 77 | --chart-1: 220 70% 50%; 78 | --chart-2: 160 60% 45%; 79 | --chart-3: 30 80% 55%; 80 | --chart-4: 280 65% 60%; 81 | --chart-5: 340 75% 55%; 82 | } 83 | } 84 | 85 | @layer base { 86 | * { 87 | @apply border-border; 88 | } 89 | body { 90 | @apply bg-background text-foreground; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /app/api/todos/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { auth } from "@clerk/nextjs/server"; 3 | import prisma from "@/lib/prisma"; 4 | 5 | const ITEMS_PER_PAGE = 10; 6 | 7 | export async function GET(req: NextRequest) { 8 | const { userId } = auth(); 9 | 10 | if (!userId) { 11 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 12 | } 13 | 14 | const { searchParams } = new URL(req.url); 15 | const page = parseInt(searchParams.get("page") || "1"); 16 | const search = searchParams.get("search") || ""; 17 | 18 | try { 19 | const todos = await prisma.todo.findMany({ 20 | where: { 21 | userId, 22 | title: { 23 | contains: search, 24 | mode: "insensitive", 25 | }, 26 | }, 27 | orderBy: { createdAt: "desc" }, 28 | take: ITEMS_PER_PAGE, 29 | skip: (page - 1) * ITEMS_PER_PAGE, 30 | }); 31 | 32 | const totalItems = await prisma.todo.count({ 33 | where: { 34 | userId, 35 | title: { 36 | contains: search, 37 | mode: "insensitive", 38 | }, 39 | }, 40 | }); 41 | 42 | const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE); 43 | 44 | return NextResponse.json({ 45 | todos, 46 | currentPage: page, 47 | totalPages, 48 | }); 49 | } catch (error) { 50 | return NextResponse.json( 51 | { error: "Internal Server Error" }, 52 | { status: 500 } 53 | ); 54 | } 55 | } 56 | 57 | export async function POST(req: NextRequest) { 58 | const { userId } = auth(); 59 | 60 | if (!userId) { 61 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 62 | } 63 | 64 | const user = await prisma.user.findUnique({ 65 | where: { id: userId }, 66 | include: { todos: true }, 67 | }); 68 | console.log("User:", user); 69 | 70 | if (!user) { 71 | return NextResponse.json({ error: "User not found" }, { status: 404 }); 72 | } 73 | 74 | if (!user.isSubscribed && user.todos.length >= 3) { 75 | return NextResponse.json( 76 | { 77 | error: 78 | "Free users can only create up to 3 todos. Please subscribe for more.", 79 | }, 80 | { status: 403 } 81 | ); 82 | } 83 | 84 | const { title } = await req.json(); 85 | 86 | const todo = await prisma.todo.create({ 87 | data: { title, userId }, 88 | }); 89 | 90 | return NextResponse.json(todo, { status: 201 }); 91 | } 92 | -------------------------------------------------------------------------------- /app/api/subscription/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { auth } from "@clerk/nextjs/server"; 3 | import prisma from "@/lib/prisma"; 4 | 5 | export async function POST() { 6 | const { userId } = auth(); 7 | 8 | if (!userId) { 9 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 10 | } 11 | 12 | try { 13 | const user = await prisma.user.findUnique({ where: { id: userId } }); 14 | 15 | if (!user) { 16 | return NextResponse.json({ error: "User not found" }, { status: 404 }); 17 | } 18 | 19 | const subscriptionEnds = new Date(); 20 | subscriptionEnds.setMonth(subscriptionEnds.getMonth() + 1); 21 | 22 | const updatedUser = await prisma.user.update({ 23 | where: { id: userId }, 24 | data: { 25 | isSubscribed: true, 26 | subscriptionEnds: subscriptionEnds, 27 | }, 28 | }); 29 | 30 | return NextResponse.json({ 31 | message: "Subscription successful", 32 | subscriptionEnds: updatedUser.subscriptionEnds, 33 | }); 34 | } catch (error) { 35 | console.error("Error updating subscription:", error); 36 | return NextResponse.json( 37 | { error: "Internal server error" }, 38 | { status: 500 } 39 | ); 40 | } 41 | } 42 | 43 | export async function GET() { 44 | const { userId } = auth(); 45 | 46 | if (!userId) { 47 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 48 | } 49 | 50 | try { 51 | const user = await prisma.user.findUnique({ 52 | where: { id: userId }, 53 | select: { isSubscribed: true, subscriptionEnds: true }, 54 | }); 55 | 56 | if (!user) { 57 | return NextResponse.json({ error: "User not found" }, { status: 404 }); 58 | } 59 | 60 | const now = new Date(); 61 | if (user.subscriptionEnds && user.subscriptionEnds < now) { 62 | await prisma.user.update({ 63 | where: { id: userId }, 64 | data: { isSubscribed: false, subscriptionEnds: null }, 65 | }); 66 | return NextResponse.json({ isSubscribed: false, subscriptionEnds: null }); 67 | } 68 | 69 | return NextResponse.json({ 70 | isSubscribed: user.isSubscribed, 71 | subscriptionEnds: user.subscriptionEnds, 72 | }); 73 | } catch (error) { 74 | console.error("Error fetching subscription status:", error); 75 | return NextResponse.json( 76 | { error: "Internal server error" }, 77 | { status: 500 } 78 | ); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { useUser, useClerk } from "@clerk/nextjs"; 5 | import { LogOut, CreditCard } from "lucide-react"; 6 | import { Button } from "@/components/ui/button"; 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuTrigger, 12 | } from "@/components/ui/dropdown-menu"; 13 | import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; 14 | 15 | export default function Navbar() { 16 | const { user } = useUser(); 17 | const { signOut } = useClerk(); 18 | 19 | return ( 20 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /app/api/webhook/register/route.ts: -------------------------------------------------------------------------------- 1 | import { Webhook } from "svix"; 2 | import { headers } from "next/headers"; 3 | import { WebhookEvent } from "@clerk/nextjs/server"; 4 | import prisma from "@/lib/prisma"; 5 | 6 | export async function POST(req: Request) { 7 | const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET; 8 | 9 | if (!WEBHOOK_SECRET) { 10 | throw new Error( 11 | "Please add WEBHOOK_SECRET from Clerk Dashboard to .env or .env.local" 12 | ); 13 | } 14 | 15 | const headerPayload = headers(); 16 | const svix_id = headerPayload.get("svix-id"); 17 | const svix_timestamp = headerPayload.get("svix-timestamp"); 18 | const svix_signature = headerPayload.get("svix-signature"); 19 | 20 | if (!svix_id || !svix_timestamp || !svix_signature) { 21 | return new Response("Error occurred -- no svix headers", { 22 | status: 400, 23 | }); 24 | } 25 | 26 | const payload = await req.json(); 27 | const body = JSON.stringify(payload); 28 | 29 | const wh = new Webhook(WEBHOOK_SECRET); 30 | let evt: WebhookEvent; 31 | 32 | try { 33 | evt = wh.verify(body, { 34 | "svix-id": svix_id, 35 | "svix-timestamp": svix_timestamp, 36 | "svix-signature": svix_signature, 37 | }) as WebhookEvent; 38 | } catch (err) { 39 | console.error("Error verifying webhook:", err); 40 | return new Response("Error occurred", { 41 | status: 400, 42 | }); 43 | } 44 | 45 | const { id } = evt.data; 46 | const eventType = evt.type; 47 | 48 | console.log(`Webhook with an ID of ${id} and type of ${eventType}`); 49 | console.log("Webhook body:", body); 50 | 51 | // Handling 'user.created' event 52 | if (eventType === "user.created") { 53 | try { 54 | const { email_addresses, primary_email_address_id } = evt.data; 55 | console.log(evt.data); 56 | // Safely find the primary email address 57 | const primaryEmail = email_addresses.find( 58 | (email) => email.id === primary_email_address_id 59 | ); 60 | console.log("Primary email:", primaryEmail); 61 | console.log("Email addresses:", primaryEmail?.email_address); 62 | 63 | if (!primaryEmail) { 64 | console.error("No primary email found"); 65 | return new Response("No primary email found", { status: 400 }); 66 | } 67 | 68 | // Create the user in the database 69 | const newUser = await prisma.user.create({ 70 | data: { 71 | id: evt.data.id!, 72 | email: primaryEmail.email_address, 73 | isSubscribed: false, // Default setting 74 | }, 75 | }); 76 | console.log("New user created:", newUser); 77 | } catch (error) { 78 | console.error("Error creating user in database:", error); 79 | return new Response("Error creating user", { status: 500 }); 80 | } 81 | } 82 | 83 | return new Response("Webhook received successfully", { status: 200 }); 84 | } 85 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TodoMaster - Role-Based Auth Todo App 2 | 3 | TodoMaster is a powerful task management application built with Next.js, featuring role-based authentication using Clerk and a PostgreSQL database with Neon. 4 | 5 | ## Getting Started 6 | 7 | ### Prerequisites 8 | 9 | - Node.js (v14 or later) 10 | - npm (v6 or later) 11 | - A Clerk account (for authentication) 12 | - A Neon account (for PostgreSQL database) 13 | 14 | ### Installation 15 | 16 | 1. Clone the repository: 17 | 18 | ``` 19 | git clone https://github.com/aryan877/todo-master.git 20 | cd role-based-auth 21 | ``` 22 | 23 | 2. Install dependencies: 24 | 25 | ``` 26 | npm install 27 | ``` 28 | 29 | 3. Set up environment variables: 30 | Create a `.env.local` file in the root directory and add the following variables: 31 | 32 | ``` 33 | # Clerk 34 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key 35 | CLERK_SECRET_KEY=your_clerk_secret_key 36 | 37 | # Neon Database 38 | DATABASE_URL=your_neon_database_url 39 | 40 | # Webhook Secret (for Clerk) 41 | WEBHOOK_SECRET=your_webhook_secret 42 | ``` 43 | 44 | 4. Set up the database: 45 | 46 | ``` 47 | npx prisma db push 48 | ``` 49 | 50 | 5. Generate Prisma client: 51 | ``` 52 | npx prisma generate 53 | ``` 54 | 55 | ### Running the Application 56 | 57 | To run the development server: 58 | 59 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 60 | 61 | ## Features 62 | 63 | - User authentication with Clerk 64 | - Role-based access control (Admin and User roles) 65 | - Todo management (Create, Read, Update, Delete) 66 | - Subscription-based todo limits 67 | - Admin dashboard for user management 68 | 69 | ## Webhook Setup 70 | 71 | This application uses a Clerk webhook to synchronize user data with the database. Specifically, it listens for the `user.created` event to create a corresponding user record in the database. 72 | 73 | To set up the webhook: 74 | 75 | 1. Go to the Clerk Dashboard. 76 | 2. Navigate to the "Webhooks" section. 77 | 3. Click on "Add Endpoint". 78 | 4. Set the Endpoint URL to `https://your-app-url.com/api/webhook/register` (replace with your actual URL). 79 | 5. Under "Events", select "user.created". 80 | 6. Save the endpoint. 81 | 7. Copy the "Signing Secret" and add it to your `.env.local` file as `WEBHOOK_SECRET`. 82 | 83 | The webhook handler is implemented in `app/api/webhook/register/route.ts`. It verifies the webhook signature and creates a new user record in the database when a user is created in Clerk. 84 | 85 | ## @Codebase 86 | 87 | ### Setting up an Admin User 88 | 89 | To test the admin functionality, you need to manually set the user's role to "admin" in Clerk. Here's how to do it: 90 | 91 | 1. Log in to your Clerk Dashboard. 92 | 2. Go to the "Users" section. 93 | 3. Find the user you want to make an admin. 94 | 4. Click on the user to open their details. 95 | 5. Scroll down to the "Public metadata" section. 96 | 6. Add a new key-value pair: 97 | - Key: `role` 98 | - Value: `admin` 99 | 7. Save the changes. 100 | 101 | Now, when this user logs in, they will have admin privileges in the application. 102 | 103 | ## Contributing 104 | 105 | Contributions are welcome! Please feel free to submit a Pull Request. 106 | 107 | ## License 108 | 109 | This project is licensed under the MIT License. 110 | -------------------------------------------------------------------------------- /app/api/admin/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { auth, clerkClient } from "@clerk/nextjs/server"; 3 | import prisma from "@/lib/prisma"; 4 | 5 | const ITEMS_PER_PAGE = 10; 6 | 7 | async function isAdmin(userId: string) { 8 | const user = await clerkClient.users.getUser(userId); 9 | return user.publicMetadata.role === "admin"; 10 | } 11 | 12 | export async function GET(req: NextRequest) { 13 | const { userId } = auth(); 14 | 15 | if (!userId || !(await isAdmin(userId))) { 16 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 17 | } 18 | 19 | const { searchParams } = new URL(req.url); 20 | const email = searchParams.get("email"); 21 | const page = parseInt(searchParams.get("page") || "1"); 22 | 23 | try { 24 | let user; 25 | if (email) { 26 | user = await prisma.user.findUnique({ 27 | where: { email }, 28 | include: { 29 | todos: { 30 | orderBy: { createdAt: "desc" }, 31 | take: ITEMS_PER_PAGE, 32 | skip: (page - 1) * ITEMS_PER_PAGE, 33 | }, 34 | }, 35 | }); 36 | } 37 | 38 | const totalItems = email 39 | ? await prisma.todo.count({ where: { user: { email } } }) 40 | : 0; 41 | const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE); 42 | 43 | return NextResponse.json({ user, totalPages, currentPage: page }); 44 | } catch (error) { 45 | return NextResponse.json( 46 | { error: "Internal Server Error" }, 47 | { status: 500 } 48 | ); 49 | } 50 | } 51 | 52 | export async function PUT(req: NextRequest) { 53 | const { userId } = auth(); 54 | 55 | if (!userId || !(await isAdmin(userId))) { 56 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 57 | } 58 | 59 | try { 60 | const { email, isSubscribed, todoId, todoCompleted, todoTitle } = 61 | await req.json(); 62 | 63 | if (isSubscribed !== undefined) { 64 | await prisma.user.update({ 65 | where: { email }, 66 | data: { 67 | isSubscribed, 68 | subscriptionEnds: isSubscribed 69 | ? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) 70 | : null, 71 | }, 72 | }); 73 | } 74 | 75 | if (todoId) { 76 | await prisma.todo.update({ 77 | where: { id: todoId }, 78 | data: { 79 | completed: todoCompleted !== undefined ? todoCompleted : undefined, 80 | title: todoTitle || undefined, 81 | }, 82 | }); 83 | } 84 | 85 | return NextResponse.json({ message: "Update successful" }); 86 | } catch (error) { 87 | return NextResponse.json( 88 | { error: "Internal Server Error" }, 89 | { status: 500 } 90 | ); 91 | } 92 | } 93 | 94 | export async function DELETE(req: NextRequest) { 95 | const { userId } = auth(); 96 | 97 | if (!userId || !(await isAdmin(userId))) { 98 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 99 | } 100 | 101 | try { 102 | const { todoId } = await req.json(); 103 | 104 | if (!todoId) { 105 | return NextResponse.json( 106 | { error: "Todo ID is required" }, 107 | { status: 400 } 108 | ); 109 | } 110 | 111 | await prisma.todo.delete({ 112 | where: { id: todoId }, 113 | }); 114 | 115 | return NextResponse.json({ message: "Todo deleted successfully" }); 116 | } catch (error) { 117 | return NextResponse.json( 118 | { error: "Internal Server Error" }, 119 | { status: 500 } 120 | ); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /app/api/admin/todos/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import { auth, clerkClient } from "@clerk/nextjs/server"; 3 | import prisma from "@/lib/prisma"; 4 | 5 | const ITEMS_PER_PAGE = 10; 6 | 7 | async function isAdmin(userId: string) { 8 | const user = await clerkClient.users.getUser(userId); 9 | return user.publicMetadata.role === "admin"; 10 | } 11 | 12 | export async function GET(req: NextRequest) { 13 | const { userId } = auth(); 14 | 15 | if (!userId) { 16 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 17 | } 18 | 19 | if (!(await isAdmin(userId))) { 20 | return NextResponse.json({ error: "Forbidden" }, { status: 403 }); 21 | } 22 | 23 | const { searchParams } = new URL(req.url); 24 | const email = searchParams.get("email"); 25 | const page = parseInt(searchParams.get("page") || "1"); 26 | 27 | try { 28 | const user = await prisma.user.findUnique({ 29 | where: { email: email || "" }, 30 | include: { 31 | todos: { 32 | orderBy: { createdAt: "desc" }, 33 | take: ITEMS_PER_PAGE, 34 | skip: (page - 1) * ITEMS_PER_PAGE, 35 | }, 36 | }, 37 | }); 38 | 39 | if (!user) { 40 | return NextResponse.json({ user: null, totalPages: 0, currentPage: 1 }); 41 | } 42 | 43 | const totalTodos = await prisma.todo.count({ 44 | where: { userId: user.id }, 45 | }); 46 | 47 | const totalPages = Math.ceil(totalTodos / ITEMS_PER_PAGE); 48 | 49 | return NextResponse.json({ 50 | user, 51 | totalPages, 52 | currentPage: page, 53 | }); 54 | } catch (error) { 55 | return NextResponse.json( 56 | { error: "Internal Server Error" }, 57 | { status: 500 } 58 | ); 59 | } 60 | } 61 | 62 | export async function PUT(req: NextRequest) { 63 | const { userId } = auth(); 64 | 65 | if (!userId) { 66 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 67 | } 68 | 69 | if (!(await isAdmin(userId))) { 70 | return NextResponse.json({ error: "Forbidden" }, { status: 403 }); 71 | } 72 | 73 | try { 74 | const { email, todoId, todoCompleted, isSubscribed } = await req.json(); 75 | 76 | if (todoId !== undefined && todoCompleted !== undefined) { 77 | // Update todo 78 | const updatedTodo = await prisma.todo.update({ 79 | where: { id: todoId }, 80 | data: { completed: todoCompleted }, 81 | }); 82 | return NextResponse.json(updatedTodo); 83 | } else if (isSubscribed !== undefined) { 84 | // Update user subscription 85 | const updatedUser = await prisma.user.update({ 86 | where: { email }, 87 | data: { 88 | isSubscribed, 89 | subscriptionEnds: isSubscribed 90 | ? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) 91 | : null, 92 | }, 93 | }); 94 | return NextResponse.json(updatedUser); 95 | } else { 96 | return NextResponse.json({ error: "Invalid request" }, { status: 400 }); 97 | } 98 | } catch (error) { 99 | return NextResponse.json( 100 | { error: "Internal Server Error" }, 101 | { status: 500 } 102 | ); 103 | } 104 | } 105 | 106 | export async function DELETE(req: NextRequest) { 107 | const { userId } = auth(); 108 | 109 | if (!userId) { 110 | return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); 111 | } 112 | 113 | if (!(await isAdmin(userId))) { 114 | return NextResponse.json({ error: "Forbidden" }, { status: 403 }); 115 | } 116 | 117 | try { 118 | const { todoId } = await req.json(); 119 | 120 | await prisma.todo.delete({ 121 | where: { id: todoId }, 122 | }); 123 | 124 | return NextResponse.json({ message: "Todo deleted successfully" }); 125 | } catch (error) { 126 | return NextResponse.json( 127 | { error: "Internal Server Error" }, 128 | { status: 500 } 129 | ); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /app/(authenticated)/subscribe/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useEffect, useState } from "react"; 4 | import { useRouter } from "next/navigation"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; 7 | import { Alert, AlertDescription } from "@/components/ui/alert"; 8 | import { CheckCircle, AlertTriangle } from "lucide-react"; 9 | import { BackButton } from "@/components/BackButton"; 10 | import { useToast } from "@/hooks/use-toast"; 11 | 12 | export default function SubscribePage() { 13 | const router = useRouter(); 14 | const { toast } = useToast(); 15 | const [isSubscribed, setIsSubscribed] = useState(false); 16 | const [subscriptionEnds, setSubscriptionEnds] = useState(null); 17 | const [isLoading, setIsLoading] = useState(true); 18 | 19 | const fetchSubscriptionStatus = useCallback(async () => { 20 | setIsLoading(true); 21 | try { 22 | const response = await fetch("/api/subscription"); 23 | if (response.ok) { 24 | const data = await response.json(); 25 | setIsSubscribed(data.isSubscribed); 26 | setSubscriptionEnds(data.subscriptionEnds); 27 | } else { 28 | throw new Error("Failed to fetch subscription status"); 29 | } 30 | } catch (error) { 31 | toast({ 32 | title: "Error", 33 | description: "Failed to fetch subscription status. Please try again.", 34 | variant: "destructive", 35 | }); 36 | } finally { 37 | setIsLoading(false); 38 | } 39 | }, [toast]); 40 | 41 | useEffect(() => { 42 | fetchSubscriptionStatus(); 43 | }, [fetchSubscriptionStatus]); 44 | 45 | const handleSubscribe = async () => { 46 | try { 47 | const response = await fetch("/api/subscription", { method: "POST" }); 48 | if (response.ok) { 49 | const data = await response.json(); 50 | setIsSubscribed(true); 51 | setSubscriptionEnds(data.subscriptionEnds); 52 | router.refresh(); 53 | toast({ 54 | title: "Success", 55 | description: "You have successfully subscribed!", 56 | }); 57 | } else { 58 | const errorData = await response.json(); 59 | throw new Error(errorData.error || "Failed to subscribe"); 60 | } 61 | } catch (error) { 62 | toast({ 63 | title: "Error", 64 | description: 65 | error instanceof Error 66 | ? error.message 67 | : "An error occurred while subscribing. Please try again.", 68 | variant: "destructive", 69 | }); 70 | } 71 | }; 72 | 73 | if (isLoading) { 74 | return
Loading...
; 75 | } 76 | 77 | return ( 78 |
79 | 80 |

Subscription

81 | 82 | 83 | Your Subscription Status 84 | 85 | 86 | {isSubscribed ? ( 87 | 88 | 89 | 90 | You are a subscribed user. Subscription ends on{" "} 91 | {new Date(subscriptionEnds!).toLocaleDateString()} 92 | 93 | 94 | ) : ( 95 | <> 96 | 97 | 98 | 99 | You are not currently subscribed. Subscribe now to unlock all 100 | features! 101 | 102 | 103 | 106 | 107 | )} 108 | 109 | 110 |
111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /app/sign-in/page.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | "use client"; 3 | 4 | import { useState } from "react"; 5 | import { useSignIn } from "@clerk/nextjs"; 6 | import { useRouter } from "next/navigation"; 7 | import Link from "next/link"; 8 | import { Input } from "@/components/ui/input"; 9 | import { Button } from "@/components/ui/button"; 10 | import { 11 | Card, 12 | CardHeader, 13 | CardTitle, 14 | CardContent, 15 | CardFooter, 16 | } from "@/components/ui/card"; 17 | import { Label } from "@/components/ui/label"; 18 | import { Alert, AlertDescription } from "@/components/ui/alert"; 19 | import { Eye, EyeOff } from "lucide-react"; 20 | 21 | export default function SignIn() { 22 | const { isLoaded, signIn, setActive } = useSignIn(); 23 | const [emailAddress, setEmailAddress] = useState(""); 24 | const [password, setPassword] = useState(""); 25 | const [error, setError] = useState(""); 26 | const [showPassword, setShowPassword] = useState(false); 27 | const router = useRouter(); 28 | 29 | if (!isLoaded) { 30 | return null; 31 | } 32 | 33 | async function submit(e: React.FormEvent) { 34 | e.preventDefault(); 35 | if (!isLoaded) { 36 | return; 37 | } 38 | 39 | try { 40 | const result = await signIn.create({ 41 | identifier: emailAddress, 42 | password, 43 | }); 44 | 45 | if (result.status === "complete") { 46 | await setActive({ session: result.createdSessionId }); 47 | router.push("/dashboard"); 48 | } else { 49 | console.error(JSON.stringify(result, null, 2)); 50 | } 51 | } catch (err: any) { 52 | console.error("error", err.errors[0].message); 53 | setError(err.errors[0].message); 54 | } 55 | } 56 | 57 | return ( 58 |
59 | 60 | 61 | 62 | Sign In to Todo Master 63 | 64 | 65 | 66 |
67 |
68 | 69 | setEmailAddress(e.target.value)} 74 | required 75 | /> 76 |
77 |
78 | 79 |
80 | setPassword(e.target.value)} 85 | required 86 | /> 87 | 98 |
99 |
100 | {error && ( 101 | 102 | {error} 103 | 104 | )} 105 | 108 |
109 |
110 | 111 |

112 | Don't have an account?{" "} 113 | 117 | Sign up 118 | 119 |

120 |
121 |
122 |
123 | ); 124 | } 125 | -------------------------------------------------------------------------------- /hooks/use-toast.ts: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | // Inspired by react-hot-toast library 4 | import * as React from "react"; 5 | 6 | import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; 7 | 8 | const TOAST_LIMIT = 1; 9 | const TOAST_REMOVE_DELAY = 1000000; 10 | 11 | type ToasterToast = ToastProps & { 12 | id: string; 13 | title?: React.ReactNode; 14 | description?: React.ReactNode; 15 | action?: ToastActionElement; 16 | }; 17 | 18 | const actionTypes = { 19 | ADD_TOAST: "ADD_TOAST", 20 | UPDATE_TOAST: "UPDATE_TOAST", 21 | DISMISS_TOAST: "DISMISS_TOAST", 22 | REMOVE_TOAST: "REMOVE_TOAST", 23 | } as const; 24 | 25 | let count = 0; 26 | 27 | function genId() { 28 | count = (count + 1) % Number.MAX_SAFE_INTEGER; 29 | return count.toString(); 30 | } 31 | 32 | type ActionType = typeof actionTypes; 33 | 34 | type Action = 35 | | { 36 | type: ActionType["ADD_TOAST"]; 37 | toast: ToasterToast; 38 | } 39 | | { 40 | type: ActionType["UPDATE_TOAST"]; 41 | toast: Partial; 42 | } 43 | | { 44 | type: ActionType["DISMISS_TOAST"]; 45 | toastId?: ToasterToast["id"]; 46 | } 47 | | { 48 | type: ActionType["REMOVE_TOAST"]; 49 | toastId?: ToasterToast["id"]; 50 | }; 51 | 52 | interface State { 53 | toasts: ToasterToast[]; 54 | } 55 | 56 | const toastTimeouts = new Map>(); 57 | 58 | const addToRemoveQueue = (toastId: string) => { 59 | if (toastTimeouts.has(toastId)) { 60 | return; 61 | } 62 | 63 | const timeout = setTimeout(() => { 64 | toastTimeouts.delete(toastId); 65 | dispatch({ 66 | type: "REMOVE_TOAST", 67 | toastId: toastId, 68 | }); 69 | }, TOAST_REMOVE_DELAY); 70 | 71 | toastTimeouts.set(toastId, timeout); 72 | }; 73 | 74 | export const reducer = (state: State, action: Action): State => { 75 | switch (action.type) { 76 | case "ADD_TOAST": 77 | return { 78 | ...state, 79 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 80 | }; 81 | 82 | case "UPDATE_TOAST": 83 | return { 84 | ...state, 85 | toasts: state.toasts.map((t) => 86 | t.id === action.toast.id ? { ...t, ...action.toast } : t 87 | ), 88 | }; 89 | 90 | case "DISMISS_TOAST": { 91 | const { toastId } = action; 92 | 93 | // ! Side effects ! - This could be extracted into a dismissToast() action, 94 | // but I'll keep it here for simplicity 95 | if (toastId) { 96 | addToRemoveQueue(toastId); 97 | } else { 98 | state.toasts.forEach((toast) => { 99 | addToRemoveQueue(toast.id); 100 | }); 101 | } 102 | 103 | return { 104 | ...state, 105 | toasts: state.toasts.map((t) => 106 | t.id === toastId || toastId === undefined 107 | ? { 108 | ...t, 109 | open: false, 110 | } 111 | : t 112 | ), 113 | }; 114 | } 115 | case "REMOVE_TOAST": 116 | if (action.toastId === undefined) { 117 | return { 118 | ...state, 119 | toasts: [], 120 | }; 121 | } 122 | return { 123 | ...state, 124 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 125 | }; 126 | } 127 | }; 128 | 129 | const listeners: Array<(state: State) => void> = []; 130 | 131 | let memoryState: State = { toasts: [] }; 132 | 133 | function dispatch(action: Action) { 134 | memoryState = reducer(memoryState, action); 135 | listeners.forEach((listener) => { 136 | listener(memoryState); 137 | }); 138 | } 139 | 140 | type Toast = Omit; 141 | 142 | function toast({ ...props }: Toast) { 143 | const id = genId(); 144 | 145 | const update = (props: ToasterToast) => 146 | dispatch({ 147 | type: "UPDATE_TOAST", 148 | toast: { ...props, id }, 149 | }); 150 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); 151 | 152 | dispatch({ 153 | type: "ADD_TOAST", 154 | toast: { 155 | ...props, 156 | id, 157 | open: true, 158 | onOpenChange: (open) => { 159 | if (!open) dismiss(); 160 | }, 161 | }, 162 | }); 163 | 164 | return { 165 | id: id, 166 | dismiss, 167 | update, 168 | }; 169 | } 170 | 171 | function useToast() { 172 | const [state, setState] = React.useState(memoryState); 173 | 174 | React.useEffect(() => { 175 | listeners.push(setState); 176 | return () => { 177 | const index = listeners.indexOf(setState); 178 | if (index > -1) { 179 | listeners.splice(index, 1); 180 | } 181 | }; 182 | }, [state]); 183 | 184 | return { 185 | ...state, 186 | toast, 187 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 188 | }; 189 | } 190 | 191 | export { useToast, toast }; 192 | -------------------------------------------------------------------------------- /components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Cross2Icon } from "@radix-ui/react-icons" 5 | import * as ToastPrimitives from "@radix-ui/react-toast" 6 | import { cva, type VariantProps } from "class-variance-authority" 7 | 8 | import { cn } from "@/lib/utils" 9 | 10 | const ToastProvider = ToastPrimitives.Provider 11 | 12 | const ToastViewport = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, ...props }, ref) => ( 16 | 24 | )) 25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName 26 | 27 | const toastVariants = cva( 28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", 29 | { 30 | variants: { 31 | variant: { 32 | default: "border bg-background text-foreground", 33 | destructive: 34 | "destructive group border-destructive bg-destructive text-destructive-foreground", 35 | }, 36 | }, 37 | defaultVariants: { 38 | variant: "default", 39 | }, 40 | } 41 | ) 42 | 43 | const Toast = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef & 46 | VariantProps 47 | >(({ className, variant, ...props }, ref) => { 48 | return ( 49 | 54 | ) 55 | }) 56 | Toast.displayName = ToastPrimitives.Root.displayName 57 | 58 | const ToastAction = React.forwardRef< 59 | React.ElementRef, 60 | React.ComponentPropsWithoutRef 61 | >(({ className, ...props }, ref) => ( 62 | 70 | )) 71 | ToastAction.displayName = ToastPrimitives.Action.displayName 72 | 73 | const ToastClose = React.forwardRef< 74 | React.ElementRef, 75 | React.ComponentPropsWithoutRef 76 | >(({ className, ...props }, ref) => ( 77 | 86 | 87 | 88 | )) 89 | ToastClose.displayName = ToastPrimitives.Close.displayName 90 | 91 | const ToastTitle = React.forwardRef< 92 | React.ElementRef, 93 | React.ComponentPropsWithoutRef 94 | >(({ className, ...props }, ref) => ( 95 | 100 | )) 101 | ToastTitle.displayName = ToastPrimitives.Title.displayName 102 | 103 | const ToastDescription = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 112 | )) 113 | ToastDescription.displayName = ToastPrimitives.Description.displayName 114 | 115 | type ToastProps = React.ComponentPropsWithoutRef 116 | 117 | type ToastActionElement = React.ReactElement 118 | 119 | export { 120 | type ToastProps, 121 | type ToastActionElement, 122 | ToastProvider, 123 | ToastViewport, 124 | Toast, 125 | ToastTitle, 126 | ToastDescription, 127 | ToastClose, 128 | ToastAction, 129 | } 130 | -------------------------------------------------------------------------------- /app/sign-up/page.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | "use client"; 3 | 4 | import { useState } from "react"; 5 | import { useSignUp } from "@clerk/nextjs"; 6 | import { useRouter } from "next/navigation"; 7 | import Link from "next/link"; 8 | import { Input } from "@/components/ui/input"; 9 | import { Button } from "@/components/ui/button"; 10 | import { 11 | Card, 12 | CardHeader, 13 | CardTitle, 14 | CardContent, 15 | CardFooter, 16 | } from "@/components/ui/card"; 17 | import { Label } from "@/components/ui/label"; 18 | import { Alert, AlertDescription } from "@/components/ui/alert"; 19 | import { Eye, EyeOff } from "lucide-react"; 20 | 21 | export default function SignUp() { 22 | const { isLoaded, signUp, setActive } = useSignUp(); 23 | const [emailAddress, setEmailAddress] = useState(""); 24 | const [password, setPassword] = useState(""); 25 | const [pendingVerification, setPendingVerification] = useState(false); 26 | const [code, setCode] = useState(""); 27 | const [error, setError] = useState(""); 28 | const [showPassword, setShowPassword] = useState(false); 29 | const router = useRouter(); 30 | 31 | if (!isLoaded) { 32 | return null; 33 | } 34 | 35 | async function submit(e: React.FormEvent) { 36 | e.preventDefault(); 37 | if (!isLoaded) { 38 | return; 39 | } 40 | 41 | try { 42 | await signUp.create({ 43 | emailAddress, 44 | password, 45 | }); 46 | 47 | await signUp.prepareEmailAddressVerification({ strategy: "email_code" }); 48 | 49 | setPendingVerification(true); 50 | } catch (err: any) { 51 | console.error(JSON.stringify(err, null, 2)); 52 | setError(err.errors[0].message); 53 | } 54 | } 55 | 56 | async function onPressVerify(e: React.FormEvent) { 57 | e.preventDefault(); 58 | if (!isLoaded) { 59 | return; 60 | } 61 | 62 | try { 63 | const completeSignUp = await signUp.attemptEmailAddressVerification({ 64 | code, 65 | }); 66 | if (completeSignUp.status !== "complete") { 67 | console.log(JSON.stringify(completeSignUp, null, 2)); 68 | } 69 | 70 | if (completeSignUp.status === "complete") { 71 | await setActive({ session: completeSignUp.createdSessionId }); 72 | router.push("/dashboard"); 73 | } 74 | } catch (err: any) { 75 | console.error(JSON.stringify(err, null, 2)); 76 | setError(err.errors[0].message); 77 | } 78 | } 79 | 80 | return ( 81 |
82 | 83 | 84 | 85 | Sign Up for Todo Master 86 | 87 | 88 | 89 | {!pendingVerification ? ( 90 |
91 |
92 | 93 | setEmailAddress(e.target.value)} 98 | required 99 | /> 100 |
101 |
102 | 103 |
104 | setPassword(e.target.value)} 109 | required 110 | /> 111 | 122 |
123 |
124 | {error && ( 125 | 126 | {error} 127 | 128 | )} 129 | 132 |
133 | ) : ( 134 |
135 |
136 | 137 | setCode(e.target.value)} 141 | placeholder="Enter verification code" 142 | required 143 | /> 144 |
145 | {error && ( 146 | 147 | {error} 148 | 149 | )} 150 | 153 |
154 | )} 155 |
156 | 157 |

158 | Already have an account?{" "} 159 | 163 | Sign in 164 | 165 |

166 |
167 |
168 |
169 | ); 170 | } 171 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@clerk/nextjs/server"; 2 | import { redirect } from "next/navigation"; 3 | import Link from "next/link"; 4 | import { List, Clock, Github, Twitter, Facebook, Users } from "lucide-react"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; 7 | 8 | export default async function Home() { 9 | const { userId } = auth(); 10 | 11 | if (userId) { 12 | redirect("/dashboard"); 13 | } 14 | 15 | return ( 16 |
17 |
18 | {/* Hero Section */} 19 | 20 | 21 | 22 | Welcome to TodoMaster 23 | 24 | 25 | 26 |

27 | Revolutionize your productivity with TodoMaster - The ultimate 28 | task management solution for professionals and teams. 29 |

30 |
31 | 34 | 37 |
38 |
39 |
40 | 41 | {/* Features Section */} 42 | 43 | 44 | 45 | Powerful Features for Effortless Task Management 46 | 47 | 48 | 49 |
50 |
51 | 52 |

53 | Smart Organization 54 |

55 |

56 | Effortlessly categorize and prioritize tasks with our 57 | intuitive interface. 58 |

59 |
60 |
61 | 62 |

63 | Intelligent Reminders 64 |

65 |

66 | Never miss a deadline with our AI-powered reminder system. 67 |

68 |
69 |
70 | 71 |

72 | Seamless Collaboration 73 |

74 |

75 | Work together effortlessly with real-time task sharing and 76 | updates. 77 |

78 |
79 |
80 |
81 |
82 | 83 | {/* Testimonials */} 84 | 85 | 86 | 87 | What Our Users Say 88 | 89 | 90 | 91 |
92 |
93 | “TodoMaster has transformed the way our team manages 94 | projects. It's intuitive, powerful, and 95 | indispensable!” 96 |
97 | - Sarah J., Project Manager 98 |
99 |
100 |
101 | “I've tried many task management apps, but TodoMaster 102 | is by far the best. It's boosted my productivity 103 | tenfold!” 104 |
105 | - Mark T., Entrepreneur 106 |
107 |
108 |
109 |
110 |
111 |
112 | 113 | {/* Footer */} 114 | 151 |
152 | ); 153 | } 154 | -------------------------------------------------------------------------------- /app/(authenticated)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useToast } from "@/hooks/use-toast"; 4 | import { useCallback, useEffect, useState } from "react"; 5 | import { TodoItem } from "@/components/TodoItem"; 6 | import { TodoForm } from "@/components/TodoForm"; 7 | import { Todo } from "@prisma/client"; 8 | import { useUser } from "@clerk/nextjs"; 9 | import { AlertTriangle } from "lucide-react"; 10 | import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; 11 | import { Alert, AlertDescription } from "@/components/ui/alert"; 12 | import { Input } from "@/components/ui/input"; 13 | import { Pagination } from "@/components/Pagination"; 14 | import Link from "next/link"; 15 | import { useDebounceValue } from "usehooks-ts"; 16 | 17 | export default function Dashboard() { 18 | const { user } = useUser(); 19 | const { toast } = useToast(); 20 | const [todos, setTodos] = useState([]); 21 | const [isSubscribed, setIsSubscribed] = useState(false); 22 | const [isLoading, setIsLoading] = useState(true); 23 | const [currentPage, setCurrentPage] = useState(1); 24 | const [totalPages, setTotalPages] = useState(1); 25 | const [searchTerm, setSearchTerm] = useState(""); 26 | const [debouncedSearchTerm] = useDebounceValue(searchTerm, 300); 27 | 28 | const fetchTodos = useCallback( 29 | async (page: number) => { 30 | try { 31 | const response = await fetch( 32 | `/api/todos?page=${page}&search=${debouncedSearchTerm}` 33 | ); 34 | if (!response.ok) { 35 | throw new Error("Failed to fetch todos"); 36 | } 37 | const data = await response.json(); 38 | setTodos(data.todos); 39 | setTotalPages(data.totalPages); 40 | setCurrentPage(data.currentPage); 41 | setIsLoading(false); 42 | toast({ 43 | title: "Success", 44 | description: "Todos fetched successfully.", 45 | }); 46 | } catch (error) { 47 | setIsLoading(false); 48 | toast({ 49 | title: "Error", 50 | description: "Failed to fetch todos. Please try again.", 51 | variant: "destructive", 52 | }); 53 | } 54 | }, 55 | [toast, debouncedSearchTerm] 56 | ); 57 | 58 | useEffect(() => { 59 | fetchTodos(1); 60 | fetchSubscriptionStatus(); 61 | }, [fetchTodos]); 62 | 63 | const fetchSubscriptionStatus = async () => { 64 | const response = await fetch("/api/subscription"); 65 | if (response.ok) { 66 | const data = await response.json(); 67 | setIsSubscribed(data.isSubscribed); 68 | } 69 | }; 70 | 71 | const handleAddTodo = async (title: string) => { 72 | toast({ 73 | title: "Adding Todo", 74 | description: "Please wait...", 75 | }); 76 | try { 77 | const response = await fetch("/api/todos", { 78 | method: "POST", 79 | headers: { "Content-Type": "application/json" }, 80 | body: JSON.stringify({ title }), 81 | }); 82 | if (!response.ok) { 83 | throw new Error("Failed to add todo"); 84 | } 85 | await fetchTodos(currentPage); 86 | toast({ 87 | title: "Success", 88 | description: "Todo added successfully.", 89 | }); 90 | } catch (error) { 91 | toast({ 92 | title: "Error", 93 | description: "Failed to add todo. Please try again.", 94 | variant: "destructive", 95 | }); 96 | } 97 | }; 98 | 99 | const handleUpdateTodo = async (id: string, completed: boolean) => { 100 | toast({ 101 | title: "Updating Todo", 102 | description: "Please wait...", 103 | }); 104 | try { 105 | const response = await fetch(`/api/todos/${id}`, { 106 | method: "PUT", 107 | headers: { "Content-Type": "application/json" }, 108 | body: JSON.stringify({ completed }), 109 | }); 110 | if (!response.ok) { 111 | throw new Error("Failed to update todo"); 112 | } 113 | await fetchTodos(currentPage); 114 | toast({ 115 | title: "Success", 116 | description: "Todo updated successfully.", 117 | }); 118 | } catch (error) { 119 | toast({ 120 | title: "Error", 121 | description: "Failed to update todo. Please try again.", 122 | variant: "destructive", 123 | }); 124 | } 125 | }; 126 | 127 | const handleDeleteTodo = async (id: string) => { 128 | toast({ 129 | title: "Deleting Todo", 130 | description: "Please wait...", 131 | }); 132 | try { 133 | const response = await fetch(`/api/todos/${id}`, { 134 | method: "DELETE", 135 | }); 136 | if (!response.ok) { 137 | throw new Error("Failed to delete todo"); 138 | } 139 | await fetchTodos(currentPage); 140 | toast({ 141 | title: "Success", 142 | description: "Todo deleted successfully.", 143 | }); 144 | } catch (error) { 145 | toast({ 146 | title: "Error", 147 | description: "Failed to delete todo. Please try again.", 148 | variant: "destructive", 149 | }); 150 | } 151 | }; 152 | 153 | return ( 154 |
155 |

156 | Welcome, {user?.emailAddresses[0].emailAddress}! 157 |

158 | 159 | 160 | Add New Todo 161 | 162 | 163 | handleAddTodo(title)} /> 164 | 165 | 166 | {!isSubscribed && todos.length >= 3 && ( 167 | 168 | 169 | 170 | You've reached the maximum number of free todos.{" "} 171 | 172 | Subscribe now 173 | {" "} 174 | to add more. 175 | 176 | 177 | )} 178 | 179 | 180 | Your Todos 181 | 182 | 183 | setSearchTerm(e.target.value)} 188 | className="mb-4" 189 | /> 190 | {isLoading ? ( 191 |

192 | Loading your todos... 193 |

194 | ) : todos.length === 0 ? ( 195 |

196 | You don't have any todos yet. Add one above! 197 |

198 | ) : ( 199 | <> 200 |
    201 | {todos.map((todo: Todo) => ( 202 | 208 | ))} 209 |
210 | fetchTodos(page)} 214 | /> 215 | 216 | )} 217 |
218 |
219 |
220 | ); 221 | } 222 | -------------------------------------------------------------------------------- /components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { 6 | CheckIcon, 7 | ChevronRightIcon, 8 | DotFilledIcon, 9 | } from "@radix-ui/react-icons" 10 | 11 | import { cn } from "@/lib/utils" 12 | 13 | const DropdownMenu = DropdownMenuPrimitive.Root 14 | 15 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 16 | 17 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 18 | 19 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 20 | 21 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 22 | 23 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 24 | 25 | const DropdownMenuSubTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef & { 28 | inset?: boolean 29 | } 30 | >(({ className, inset, children, ...props }, ref) => ( 31 | 40 | {children} 41 | 42 | 43 | )) 44 | DropdownMenuSubTrigger.displayName = 45 | DropdownMenuPrimitive.SubTrigger.displayName 46 | 47 | const DropdownMenuSubContent = React.forwardRef< 48 | React.ElementRef, 49 | React.ComponentPropsWithoutRef 50 | >(({ className, ...props }, ref) => ( 51 | 59 | )) 60 | DropdownMenuSubContent.displayName = 61 | DropdownMenuPrimitive.SubContent.displayName 62 | 63 | const DropdownMenuContent = React.forwardRef< 64 | React.ElementRef, 65 | React.ComponentPropsWithoutRef 66 | >(({ className, sideOffset = 4, ...props }, ref) => ( 67 | 68 | 78 | 79 | )) 80 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 81 | 82 | const DropdownMenuItem = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef & { 85 | inset?: boolean 86 | } 87 | >(({ className, inset, ...props }, ref) => ( 88 | 97 | )) 98 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 99 | 100 | const DropdownMenuCheckboxItem = React.forwardRef< 101 | React.ElementRef, 102 | React.ComponentPropsWithoutRef 103 | >(({ className, children, checked, ...props }, ref) => ( 104 | 113 | 114 | 115 | 116 | 117 | 118 | {children} 119 | 120 | )) 121 | DropdownMenuCheckboxItem.displayName = 122 | DropdownMenuPrimitive.CheckboxItem.displayName 123 | 124 | const DropdownMenuRadioItem = React.forwardRef< 125 | React.ElementRef, 126 | React.ComponentPropsWithoutRef 127 | >(({ className, children, ...props }, ref) => ( 128 | 136 | 137 | 138 | 139 | 140 | 141 | {children} 142 | 143 | )) 144 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 145 | 146 | const DropdownMenuLabel = React.forwardRef< 147 | React.ElementRef, 148 | React.ComponentPropsWithoutRef & { 149 | inset?: boolean 150 | } 151 | >(({ className, inset, ...props }, ref) => ( 152 | 161 | )) 162 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 163 | 164 | const DropdownMenuSeparator = React.forwardRef< 165 | React.ElementRef, 166 | React.ComponentPropsWithoutRef 167 | >(({ className, ...props }, ref) => ( 168 | 173 | )) 174 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 175 | 176 | const DropdownMenuShortcut = ({ 177 | className, 178 | ...props 179 | }: React.HTMLAttributes) => { 180 | return ( 181 | 185 | ) 186 | } 187 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 188 | 189 | export { 190 | DropdownMenu, 191 | DropdownMenuTrigger, 192 | DropdownMenuContent, 193 | DropdownMenuItem, 194 | DropdownMenuCheckboxItem, 195 | DropdownMenuRadioItem, 196 | DropdownMenuLabel, 197 | DropdownMenuSeparator, 198 | DropdownMenuShortcut, 199 | DropdownMenuGroup, 200 | DropdownMenuPortal, 201 | DropdownMenuSub, 202 | DropdownMenuSubContent, 203 | DropdownMenuSubTrigger, 204 | DropdownMenuRadioGroup, 205 | } 206 | -------------------------------------------------------------------------------- /app/(authenticated)/admin/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState, useCallback, useEffect } from "react"; 4 | import { useToast } from "@/hooks/use-toast"; 5 | import { TodoItem } from "@/components/TodoItem"; 6 | import { Todo, User } from "@prisma/client"; 7 | import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; 8 | import { Input } from "@/components/ui/input"; 9 | import { Button } from "@/components/ui/button"; 10 | import { Pagination } from "@/components/Pagination"; 11 | import { useDebounceValue } from "usehooks-ts"; 12 | 13 | interface UserWithTodos extends User { 14 | todos: Todo[]; 15 | } 16 | 17 | export default function AdminDashboard() { 18 | const { toast } = useToast(); 19 | const [email, setEmail] = useState(""); 20 | const [debouncedEmail, setDebouncedEmail] = useDebounceValue("", 300); 21 | const [user, setUser] = useState(null); 22 | const [isLoading, setIsLoading] = useState(false); 23 | const [currentPage, setCurrentPage] = useState(1); 24 | const [totalPages, setTotalPages] = useState(1); 25 | 26 | const fetchUserData = useCallback( 27 | async (page: number) => { 28 | setIsLoading(true); 29 | try { 30 | const response = await fetch( 31 | `/api/admin?email=${debouncedEmail}&page=${page}` 32 | ); 33 | if (!response.ok) throw new Error("Failed to fetch user data"); 34 | const data = await response.json(); 35 | setUser(data.user); 36 | setTotalPages(data.totalPages); 37 | setCurrentPage(data.currentPage); 38 | toast({ 39 | title: "Success", 40 | description: "User data fetched successfully.", 41 | }); 42 | } catch (error) { 43 | toast({ 44 | title: "Error", 45 | description: "Failed to fetch user data. Please try again.", 46 | variant: "destructive", 47 | }); 48 | } finally { 49 | setIsLoading(false); 50 | } 51 | }, 52 | [debouncedEmail, toast] 53 | ); 54 | 55 | useEffect(() => { 56 | if (debouncedEmail) { 57 | fetchUserData(1); 58 | } 59 | }, [debouncedEmail, fetchUserData]); 60 | 61 | const handleSearch = (e: React.FormEvent) => { 62 | e.preventDefault(); 63 | setDebouncedEmail(email); 64 | }; 65 | 66 | const handleUpdateSubscription = async () => { 67 | toast({ 68 | title: "Updating Subscription", 69 | description: "Please wait...", 70 | }); 71 | try { 72 | const response = await fetch("/api/admin", { 73 | method: "PUT", 74 | headers: { "Content-Type": "application/json" }, 75 | body: JSON.stringify({ 76 | email: debouncedEmail, 77 | isSubscribed: !user?.isSubscribed, 78 | }), 79 | }); 80 | if (!response.ok) throw new Error("Failed to update subscription"); 81 | fetchUserData(currentPage); 82 | toast({ 83 | title: "Success", 84 | description: "Subscription updated successfully.", 85 | }); 86 | } catch (error) { 87 | toast({ 88 | title: "Error", 89 | description: "Failed to update subscription. Please try again.", 90 | variant: "destructive", 91 | }); 92 | } 93 | }; 94 | 95 | const handleUpdateTodo = async (id: string, completed: boolean) => { 96 | toast({ 97 | title: "Updating Todo", 98 | description: "Please wait...", 99 | }); 100 | try { 101 | const response = await fetch("/api/admin", { 102 | method: "PUT", 103 | headers: { "Content-Type": "application/json" }, 104 | body: JSON.stringify({ 105 | email: debouncedEmail, 106 | todoId: id, 107 | todoCompleted: completed, 108 | }), 109 | }); 110 | if (!response.ok) throw new Error("Failed to update todo"); 111 | fetchUserData(currentPage); 112 | toast({ title: "Success", description: "Todo updated successfully." }); 113 | } catch (error) { 114 | toast({ 115 | title: "Error", 116 | description: "Failed to update todo. Please try again.", 117 | variant: "destructive", 118 | }); 119 | } 120 | }; 121 | 122 | const handleDeleteTodo = async (id: string) => { 123 | toast({ 124 | title: "Deleting Todo", 125 | description: "Please wait...", 126 | }); 127 | try { 128 | const response = await fetch("/api/admin", { 129 | method: "DELETE", 130 | headers: { "Content-Type": "application/json" }, 131 | body: JSON.stringify({ todoId: id }), 132 | }); 133 | if (!response.ok) throw new Error("Failed to delete todo"); 134 | fetchUserData(currentPage); 135 | toast({ title: "Success", description: "Todo deleted successfully." }); 136 | } catch (error) { 137 | toast({ 138 | title: "Error", 139 | description: "Failed to delete todo. Please try again.", 140 | variant: "destructive", 141 | }); 142 | } 143 | }; 144 | 145 | return ( 146 |
147 |

Admin Dashboard

148 | 149 | 150 | Search User 151 | 152 | 153 |
154 | setEmail(e.target.value)} 158 | placeholder="Enter user email" 159 | required 160 | /> 161 | 162 |
163 |
164 |
165 | 166 | {isLoading ? ( 167 | 168 | 169 |

Loading user data...

170 |
171 |
172 | ) : user ? ( 173 | <> 174 | 175 | 176 | User Details 177 | 178 | 179 |

Email: {user.email}

180 |

181 | Subscription Status:{" "} 182 | {user.isSubscribed ? "Subscribed" : "Not Subscribed"} 183 |

184 | {user.subscriptionEnds && ( 185 |

186 | Subscription Ends:{" "} 187 | {new Date(user.subscriptionEnds).toLocaleDateString()} 188 |

189 | )} 190 | 193 |
194 |
195 | 196 | {user.todos.length > 0 ? ( 197 | 198 | 199 | User Todos 200 | 201 | 202 |
    203 | {user.todos.map((todo) => ( 204 | 211 | ))} 212 |
213 | fetchUserData(page)} 217 | /> 218 |
219 |
220 | ) : ( 221 | 222 | 223 |

This user has no todos.

224 |
225 |
226 | )} 227 | 228 | ) : debouncedEmail ? ( 229 | 230 | 231 |

232 | No user found with this email. 233 |

234 |
235 |
236 | ) : null} 237 |
238 | ); 239 | } 240 | --------------------------------------------------------------------------------