├── postcss.config.mjs ├── next.config.ts ├── src ├── app │ ├── session │ │ └── route.ts │ ├── api │ │ ├── auth │ │ │ ├── session │ │ │ │ └── route.ts │ │ │ ├── signin │ │ │ │ └── route.ts │ │ │ ├── signout │ │ │ │ └── route.ts │ │ │ ├── error │ │ │ │ └── route.ts │ │ │ ├── register │ │ │ │ └── route.ts │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ ├── debug │ │ │ ├── mongodb │ │ │ │ └── route.ts │ │ │ └── auth │ │ │ │ └── route.ts │ │ ├── stats │ │ │ └── route.ts │ │ ├── todos │ │ │ ├── route.ts │ │ │ └── [id] │ │ │ │ └── route.ts │ │ ├── users │ │ │ └── profile │ │ │ │ └── route.ts │ │ └── admin │ │ │ ├── todos │ │ │ ├── route.ts │ │ │ └── [id] │ │ │ │ └── route.ts │ │ │ └── users │ │ │ ├── [id] │ │ │ └── route.ts │ │ │ └── route.ts │ ├── callback │ │ └── route.ts │ ├── csrf │ │ └── route.ts │ ├── signin │ │ └── route.ts │ ├── signout │ │ └── route.ts │ ├── auth │ │ ├── page.tsx │ │ ├── layout.tsx │ │ ├── debug │ │ │ └── page.tsx │ │ ├── error │ │ │ └── page.tsx │ │ ├── signin │ │ │ └── page.tsx │ │ └── signup │ │ │ └── page.tsx │ ├── dashboard │ │ ├── todos │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── profile │ │ │ └── page.tsx │ │ └── admin │ │ │ └── tasks │ │ │ └── page.tsx │ ├── providers.tsx │ ├── layout.tsx │ ├── page.tsx │ └── globals.css ├── types │ ├── todo.ts │ └── next-auth.d.ts ├── features │ └── todos │ │ ├── store.ts │ │ ├── api.ts │ │ ├── TodoStats.tsx │ │ └── ToDoList.tsx ├── store │ └── index.ts ├── components │ ├── ui │ │ └── Toaster.tsx │ ├── LoadingSpinner.tsx │ ├── ThemeToggle.tsx │ ├── GoogleAuthButton.tsx │ ├── todos │ │ ├── EditableTodoItem.tsx │ │ └── TodoItem.tsx │ └── Header.tsx ├── models │ ├── user.interface.ts │ ├── Todo.ts │ └── User.ts └── lib │ ├── auth.ts │ ├── toast.ts │ ├── permissions │ ├── server.ts │ └── types.ts │ ├── mongodb.ts │ └── permissions.ts ├── .prettierrc.json ├── .prettierignore ├── .vscode └── tasks.json ├── remove_comments.sh ├── .gitignore ├── tsconfig.json ├── eslint.config.mjs ├── db.json ├── package.json ├── EditableTodoItem.tsx └── README.md /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | const config = { 2 | plugins: ['@tailwindcss/postcss'], 3 | }; 4 | export default config; 5 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from 'next'; 2 | const nextConfig: NextConfig = {}; 3 | export default nextConfig; 4 | -------------------------------------------------------------------------------- /src/app/session/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from '@/app/api/auth/[...nextauth]/route'; 2 | export const GET = handlers.GET; 3 | -------------------------------------------------------------------------------- /src/app/api/auth/session/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from '@/app/api/auth/[...nextauth]/route'; 2 | export const GET = handlers.GET; 3 | -------------------------------------------------------------------------------- /src/app/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from '@/app/api/auth/[...nextauth]/route'; 2 | export const GET = handlers.GET; 3 | export const POST = handlers.POST; 4 | -------------------------------------------------------------------------------- /src/app/csrf/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from '@/app/api/auth/[...nextauth]/route'; 2 | export const GET = handlers.GET; 3 | export const POST = handlers.POST; 4 | -------------------------------------------------------------------------------- /src/app/signin/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from '@/app/api/auth/[...nextauth]/route'; 2 | export const GET = handlers.GET; 3 | export const POST = handlers.POST; 4 | -------------------------------------------------------------------------------- /src/app/signout/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from '@/app/api/auth/[...nextauth]/route'; 2 | export const GET = handlers.GET; 3 | export const POST = handlers.POST; 4 | -------------------------------------------------------------------------------- /src/app/api/auth/signin/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from '@/app/api/auth/[...nextauth]/route'; 2 | export const GET = handlers.GET; 3 | export const POST = handlers.POST; 4 | -------------------------------------------------------------------------------- /src/app/api/auth/signout/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from '@/app/api/auth/[...nextauth]/route'; 2 | export const GET = handlers.GET; 3 | export const POST = handlers.POST; 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "es5", 6 | "printWidth": 100, 7 | "bracketSpacing": true, 8 | "arrowParens": "avoid", 9 | "endOfLine": "lf" 10 | } 11 | -------------------------------------------------------------------------------- /src/types/todo.ts: -------------------------------------------------------------------------------- 1 | export interface ITodo { 2 | id: string | number; 3 | title: string; 4 | completed: boolean; 5 | } 6 | export interface IUser { 7 | id: string; 8 | name: string; 9 | email: string; 10 | image?: string; 11 | } 12 | -------------------------------------------------------------------------------- /src/app/auth/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useEffect } from 'react'; 3 | import { useRouter } from 'next/navigation'; 4 | export default function AuthPage() { 5 | const router = useRouter(); 6 | useEffect(() => { 7 | router.replace('/auth/signin'); 8 | }, [router]); 9 | return null; 10 | } 11 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Build output 2 | .next/ 3 | build/ 4 | dist/ 5 | out/ 6 | 7 | # Dependencies 8 | node_modules/ 9 | 10 | # Generated files 11 | coverage/ 12 | .vercel 13 | .env*.local 14 | 15 | # Logs 16 | *.log 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | 21 | # Misc 22 | .DS_Store 23 | *.pem 24 | -------------------------------------------------------------------------------- /src/app/api/auth/error/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | export async function GET(req: NextRequest) { 3 | const searchParams = req.nextUrl.searchParams; 4 | const error = searchParams.get('error'); 5 | return NextResponse.redirect( 6 | new URL(`/auth/error?error=${encodeURIComponent(error || '')}`, req.url) 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Restart Next.js Server", 6 | "type": "shell", 7 | "command": "cd /run/media/sohan-shell/Programming/Boomdeves/rtk-zustand-todo && npm run dev", 8 | "problemMatcher": [], 9 | "group": "none", 10 | "isBackground": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /src/features/todos/store.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | import { create } from 'zustand'; 3 | type ITodoInputStore = { 4 | input: string; 5 | setInput: (value: string) => void; 6 | reset: () => void; 7 | }; 8 | export const useTodoInputStore = create(set => ({ 9 | input: '', 10 | setInput: value => set({ input: value }), 11 | reset: () => set({ input: '' }), 12 | })); 13 | -------------------------------------------------------------------------------- /src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import { todosApi } from '@/features/todos/api'; 3 | export const store = configureStore({ 4 | reducer: { 5 | [todosApi.reducerPath]: todosApi.reducer, 6 | }, 7 | middleware: getDefaultMiddleware => getDefaultMiddleware().concat(todosApi.middleware), 8 | }); 9 | export type RootState = ReturnType; 10 | export type AppDispatch = typeof store.dispatch; 11 | -------------------------------------------------------------------------------- /src/components/ui/Toaster.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Toaster as SonnerToaster } from 'sonner'; 4 | import { useTheme } from 'next-themes'; 5 | 6 | export function Toaster() { 7 | const { theme } = useTheme(); 8 | 9 | return ( 10 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/models/user.interface.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | export type UserRole = 'user' | 'admin' | 'super-admin'; 4 | 5 | export interface AdminPermissions { 6 | canUpdateUserInfo: boolean; 7 | canDeleteUsers: boolean; 8 | canPromoteToAdmin: boolean; 9 | canDemoteAdmins: boolean; 10 | } 11 | 12 | export interface IUser { 13 | _id: mongoose.Types.ObjectId; 14 | name: string; 15 | email: string; 16 | password: string; 17 | image?: string; 18 | role: UserRole; 19 | adminPermissions?: AdminPermissions; 20 | createdAt: Date; 21 | updatedAt: Date; 22 | } 23 | -------------------------------------------------------------------------------- /src/app/dashboard/todos/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useSession } from 'next-auth/react'; 3 | import ToDoList from '@/features/todos/ToDoList'; 4 | import TodoStats from '@/features/todos/TodoStats'; 5 | export default function TodosPage() { 6 | const { data: session } = useSession(); 7 | return ( 8 |
9 |

My Tasks

10 |
11 | 12 |
13 |
14 | 15 |
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { ReactNode } from 'react'; 3 | import { Provider } from 'react-redux'; 4 | import { store } from '@/store'; 5 | import { ThemeProvider } from 'next-themes'; 6 | import { SessionProvider } from 'next-auth/react'; 7 | export function Providers({ children }: { children: ReactNode }) { 8 | return ( 9 | 10 | 11 | {children} 12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /remove_comments.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to remove comments from typescript/javascript files 4 | find . -type f \( -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" -o -name "*.mjs" \) -exec sed -i \ 5 | -e 's|\/\/.*$||g' \ 6 | -e 's|\/\*.*\*\/||g' \ 7 | -e '/^[ \t]*\/\*/,/\*\//d' \ 8 | -e 's|[ \t]*$||g' \ 9 | -e '/^$/d' \ 10 | {} \; 11 | 12 | # Special handling for CSS files 13 | find . -type f -name "*.css" -exec sed -i \ 14 | -e 's|\/\*.*\*\/||g' \ 15 | -e '/^[ \t]*\/\*/,/\*\//d' \ 16 | -e 's|[ \t]*$||g' \ 17 | {} \; 18 | 19 | echo "Comments have been removed from the codebase." 20 | # ./remove_comments.sh -------------------------------------------------------------------------------- /src/app/auth/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React from 'react'; 3 | import { useSession } from 'next-auth/react'; 4 | import { useRouter } from 'next/navigation'; 5 | import { useEffect } from 'react'; 6 | export default function AuthLayout({ children }: { children: React.ReactNode }) { 7 | const { status } = useSession(); 8 | const router = useRouter(); 9 | useEffect(() => { 10 | if (status === 'authenticated') { 11 | router.push('/dashboard'); 12 | } 13 | }, [status, router]); 14 | return ( 15 |
16 |
{children}
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /.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.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env* 35 | 36 | # vercel 37 | .vercel 38 | 39 | # typescript 40 | *.tsbuildinfo 41 | next-env.d.ts 42 | -------------------------------------------------------------------------------- /src/types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import 'next-auth'; 2 | import { UserRole, AdminPermissions } from '@/models/user.interface'; 3 | declare module 'next-auth' { 4 | interface User { 5 | id: string; 6 | role?: UserRole; 7 | adminPermissions?: AdminPermissions; 8 | } 9 | interface Session { 10 | user: { 11 | id: string; 12 | name?: string | null; 13 | email?: string | null; 14 | image?: string | null; 15 | role: UserRole; 16 | adminPermissions?: AdminPermissions; 17 | }; 18 | } 19 | } 20 | declare module 'next-auth/jwt' { 21 | interface JWT { 22 | id: string; 23 | role: UserRole; 24 | adminPermissions?: AdminPermissions; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt'; 2 | const AUTH_CONFIG = { 3 | saltRounds: 10, 4 | minPasswordLength: 8, 5 | }; 6 | export async function hashPassword(password: string): Promise { 7 | if (!password) { 8 | throw new Error('Password is required'); 9 | } 10 | if (password.length < AUTH_CONFIG.minPasswordLength) { 11 | throw new Error(`Password must be at least ${AUTH_CONFIG.minPasswordLength} characters long`); 12 | } 13 | return await bcrypt.hash(password, AUTH_CONFIG.saltRounds); 14 | } 15 | export async function verifyPassword( 16 | plainPassword: string, 17 | hashedPassword: string 18 | ): Promise { 19 | if (!plainPassword || !hashedPassword) { 20 | return false; 21 | } 22 | return await bcrypt.compare(plainPassword, hashedPassword); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/LoadingSpinner.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | export interface LoadingSpinnerProps { 3 | size?: 'small' | 'medium' | 'large'; 4 | color?: 'primary' | 'secondary' | 'white'; 5 | } 6 | export default function LoadingSpinner({ 7 | size = 'medium', 8 | color = 'primary', 9 | }: LoadingSpinnerProps) { 10 | const sizeClasses = { 11 | small: 'h-4 w-4', 12 | medium: 'h-8 w-8', 13 | large: 'h-12 w-12', 14 | }; 15 | const colorClasses = { 16 | primary: 'border-indigo-500', 17 | secondary: 'border-gray-500', 18 | white: 'border-white', 19 | }; 20 | return ( 21 |
22 |
25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /src/app/api/debug/mongodb/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { connectToDatabase } from '@/lib/mongodb'; 3 | import mongoose from 'mongoose'; 4 | export async function GET() { 5 | try { 6 | const connection = await connectToDatabase(); 7 | return NextResponse.json( 8 | { 9 | status: 'Connected to MongoDB', 10 | databaseName: connection.db?.databaseName || 'unknown', 11 | connected: connection.readyState === mongoose.ConnectionStates.connected, 12 | readyState: connection.readyState, 13 | }, 14 | { status: 200 } 15 | ); 16 | } catch (_error) { 17 | return NextResponse.json( 18 | { 19 | status: 'Error connecting to MongoDB', 20 | error: error instanceof Error ? error.message : String(error), 21 | }, 22 | { status: 500 } 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import './globals.css'; 2 | import type { Metadata } from 'next'; 3 | import { ReactNode } from 'react'; 4 | import { Providers } from './providers'; 5 | import Header from '@/components/Header'; 6 | import { Toaster } from '@/components/ui/Toaster'; 7 | export const metadata: Metadata = { 8 | title: 'TaskMaster - Manage Your Tasks', 9 | description: 'A simple task management application', 10 | }; 11 | export default function RootLayout({ children }: { children: ReactNode }) { 12 | return ( 13 | 14 | 15 | 16 |
17 |
18 |
{children}
19 |
20 | 21 |
22 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/models/Todo.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { ITodo } from '@/types/todo'; 3 | export interface IMongoTodo extends Omit { 4 | _id: mongoose.Types.ObjectId; 5 | userId: mongoose.Types.ObjectId; 6 | createdAt: Date; 7 | updatedAt: Date; 8 | } 9 | const TodoSchema = new mongoose.Schema( 10 | { 11 | title: { 12 | type: String, 13 | required: [true, 'Please provide a title for this todo.'], 14 | maxlength: [100, 'Title cannot be more than 100 characters'], 15 | }, 16 | completed: { 17 | type: Boolean, 18 | default: false, 19 | }, 20 | userId: { 21 | type: mongoose.Schema.Types.ObjectId, 22 | ref: 'User', 23 | required: true, 24 | }, 25 | }, 26 | { 27 | timestamps: true, 28 | } 29 | ); 30 | export default mongoose.models.Todo || mongoose.model('Todo', TodoSchema); 31 | -------------------------------------------------------------------------------- /src/lib/toast.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { toast } from 'sonner'; 4 | 5 | // Toast utility functions 6 | export const showToast = { 7 | success: (message: string) => { 8 | toast.success(message); 9 | }, 10 | error: (message: string) => { 11 | toast.error(message); 12 | }, 13 | warning: (message: string) => { 14 | toast.warning(message); 15 | }, 16 | info: (message: string, _error: unknown) => { 17 | toast.info(message); 18 | }, 19 | loading: (message: string) => { 20 | return toast.loading(message); 21 | }, 22 | promise: ( 23 | promise: Promise, 24 | messages: { 25 | loading: string; 26 | success: string; 27 | error: string; 28 | } 29 | ) => { 30 | return toast.promise(promise, { 31 | loading: messages.loading, 32 | success: messages.success, 33 | error: messages.error, 34 | }); 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from 'path'; 2 | import { fileURLToPath } from 'url'; 3 | import { FlatCompat } from '@eslint/eslintrc'; 4 | const __filename = fileURLToPath(import.meta.url); 5 | const __dirname = dirname(__filename); 6 | const compat = new FlatCompat({ 7 | baseDirectory: __dirname, 8 | }); 9 | import eslintPluginPrettier from 'eslint-plugin-prettier'; 10 | const eslintConfig = [ 11 | ...compat.extends('next/core-web-vitals', 'next/typescript'), 12 | ...compat.extends('prettier'), 13 | { 14 | files: ['**/*.{js,jsx,ts,tsx}'], 15 | plugins: { 16 | prettier: eslintPluginPrettier, 17 | }, 18 | rules: { 19 | 'no-var': 'error', 20 | 'no-unused-vars': 'error', 21 | 'no-unused-expressions': 'error', 22 | 'prefer-const': 'error', 23 | 'no-console': 'warn', 24 | 'no-undef': 'error', 25 | }, 26 | }, 27 | ]; 28 | export default eslintConfig; 29 | -------------------------------------------------------------------------------- /db.json: -------------------------------------------------------------------------------- 1 | { 2 | "todos": [ 3 | { 4 | "id": "1", 5 | "title": "Learn Zustand", 6 | "completed": true 7 | }, 8 | { 9 | "id": "2", 10 | "title": "Learn RTK Query", 11 | "completed": true 12 | }, 13 | { 14 | "id": "3", 15 | "title": "Set up Next.js project", 16 | "completed": true 17 | }, 18 | { 19 | "id": "4", 20 | "title": "Create TodoList component", 21 | "completed": true 22 | }, 23 | { 24 | "id": "5", 25 | "title": "Connect Zustand store", 26 | "completed": true 27 | }, 28 | { 29 | "id": "9", 30 | "title": "Delete Todo item", 31 | "completed": true 32 | }, 33 | { 34 | "id": "10", 35 | "title": "Style with Tailwind CSS", 36 | "completed": true 37 | }, 38 | { 39 | "id": "3abe", 40 | "title": "hhhh", 41 | "completed": false 42 | }, 43 | { 44 | "id": "c24f", 45 | "title": "etryuiohjpk", 46 | "completed": false 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /src/app/api/debug/auth/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { auth } from '@/app/api/auth/[...nextauth]/route'; 3 | import { showToast } from '@/lib/toast'; 4 | export async function GET() { 5 | try { 6 | const session = await auth(); 7 | if (session) { 8 | return NextResponse.json( 9 | { 10 | status: 'Authenticated', 11 | user: { 12 | id: session.user?.id, 13 | name: session.user?.name, 14 | email: session.user?.email, 15 | }, 16 | }, 17 | { status: 200 } 18 | ); 19 | } else { 20 | return NextResponse.json( 21 | { 22 | status: 'Not authenticated', 23 | session: null, 24 | }, 25 | { status: 401 } 26 | ); 27 | } 28 | } catch (error) { 29 | showToast.info('Error checking authentication', error); 30 | return NextResponse.json( 31 | { 32 | status: 'Error checking authentication', 33 | error: error instanceof Error ? error.message : String(error), 34 | }, 35 | { status: 500 } 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/lib/permissions/server.ts: -------------------------------------------------------------------------------- 1 | import { cache } from 'react'; 2 | import { auth } from '@/app/api/auth/[...nextauth]/route'; 3 | import { type Permission, getAllUserPermissions } from '@/lib/permissions/types'; 4 | export const getUserPermissions = cache(async (): Promise => { 5 | const session = await auth(); 6 | if (!session?.user) { 7 | return []; 8 | } 9 | return getAllUserPermissions(session.user.role, session.user.adminPermissions); 10 | }); 11 | export async function hasPermission(permission: Permission): Promise { 12 | const permissions = await getUserPermissions(); 13 | return permissions.includes(permission); 14 | } 15 | export async function hasAnyPermission(requiredPermissions: Permission[]): Promise { 16 | const userPermissions = await getUserPermissions(); 17 | return requiredPermissions.some(permission => userPermissions.includes(permission)); 18 | } 19 | export async function hasAllPermissions(requiredPermissions: Permission[]): Promise { 20 | const userPermissions = await getUserPermissions(); 21 | return requiredPermissions.every(permission => userPermissions.includes(permission)); 22 | } 23 | -------------------------------------------------------------------------------- /src/app/api/auth/register/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { connectToDatabase } from '@/lib/mongodb'; 3 | import UserModel from '@/models/User'; 4 | import { hashPassword } from '@/lib/auth'; 5 | export async function POST(req: NextRequest) { 6 | try { 7 | const body = await req.json(); 8 | const { name, email, password } = body; 9 | if (!name || !email || !password) { 10 | return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); 11 | } 12 | await connectToDatabase(); 13 | const existingUser = await UserModel.findOne({ email }); 14 | if (existingUser) { 15 | return NextResponse.json({ error: 'Email already exists' }, { status: 409 }); 16 | } 17 | const hashedPassword = await hashPassword(password); 18 | const user = await UserModel.create({ 19 | name, 20 | email, 21 | password: hashedPassword, 22 | role: 'user', 23 | }); 24 | const userObject = user.toObject(); 25 | delete userObject.password; 26 | return NextResponse.json( 27 | { message: 'User created successfully', user: userObject }, 28 | { status: 201 } 29 | ); 30 | } catch (_error) { 31 | return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/mongodb.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | const MONGODB_CONFIG = { 3 | uri: process.env.MONGODB_URI, 4 | options: { 5 | bufferCommands: false, 6 | connectTimeoutMS: 10000, 7 | socketTimeoutMS: 45000, 8 | }, 9 | }; 10 | declare global { 11 | var mongoose: { 12 | conn: mongoose.Connection | null; 13 | promise: Promise | null; 14 | }; 15 | } 16 | if (!global.mongoose) { 17 | global.mongoose = { 18 | conn: null, 19 | promise: null, 20 | }; 21 | } 22 | export async function connectToDatabase(): Promise { 23 | if (global.mongoose.conn) { 24 | return global.mongoose.conn; 25 | } 26 | if (!global.mongoose.promise) { 27 | if (!MONGODB_CONFIG.uri) { 28 | throw new Error( 29 | 'MongoDB connection error: Please define the MONGODB_URI environment variable in .env.local' 30 | ); 31 | } 32 | global.mongoose.promise = mongoose 33 | .connect(MONGODB_CONFIG.uri, MONGODB_CONFIG.options) 34 | .then(mongoose => mongoose.connection); 35 | } 36 | try { 37 | global.mongoose.conn = await global.mongoose.promise; 38 | return global.mongoose.conn; 39 | } catch (_error) { 40 | throw new Error( 41 | `MongoDB connection failed: ${error instanceof Error ? error.message : String(error)}` 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rtk-zustand-todo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "json-server": "json-server --watch db.json --port 4000", 8 | "build": "next build", 9 | "start": "next start", 10 | "lint": "next lint", 11 | "lint:fix": "next lint --fix", 12 | "prettier:fix": "prettier --write .", 13 | "format:check": "prettier --check ." 14 | }, 15 | "dependencies": { 16 | "@reduxjs/toolkit": "^2.8.2", 17 | "@tanstack/react-query": "^5.83.0", 18 | "bcrypt": "^6.0.0", 19 | "mongodb": "^6.17.0", 20 | "mongoose": "^8.16.4", 21 | "next": "15.4.1", 22 | "next-auth": "^5.0.0-beta.29", 23 | "next-themes": "^0.4.6", 24 | "react": "19.1.0", 25 | "react-dom": "19.1.0", 26 | "react-redux": "^9.2.0", 27 | "sonner": "^2.0.6", 28 | "zustand": "^5.0.6" 29 | }, 30 | "devDependencies": { 31 | "@eslint/eslintrc": "^3", 32 | "@tailwindcss/postcss": "^4", 33 | "@types/bcrypt": "^5.0.2", 34 | "@types/mongoose": "^5.11.97", 35 | "@types/node": "^20", 36 | "@types/react": "^19", 37 | "@types/react-dom": "^19", 38 | "eslint": "^9", 39 | "eslint-config-next": "15.4.1", 40 | "eslint-config-prettier": "^10.1.5", 41 | "eslint-plugin-prettier": "^5.5.1", 42 | "json-server": "^1.0.0-beta.3", 43 | "prettier": "^3.6.2", 44 | "tailwindcss": "^4", 45 | "typescript": "^5" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/models/User.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | import { IUser, AdminPermissions } from './user.interface'; 3 | const AdminPermissionsSchema = new mongoose.Schema( 4 | { 5 | canUpdateUserInfo: { 6 | type: Boolean, 7 | default: true, 8 | }, 9 | canDeleteUsers: { 10 | type: Boolean, 11 | default: false, 12 | }, 13 | canPromoteToAdmin: { 14 | type: Boolean, 15 | default: false, 16 | }, 17 | canDemoteAdmins: { 18 | type: Boolean, 19 | default: false, 20 | }, 21 | }, 22 | { _id: false } 23 | ); 24 | const UserSchema = new mongoose.Schema( 25 | { 26 | name: { 27 | type: String, 28 | required: [true, 'Please provide your name'], 29 | maxlength: [60, 'Name cannot be more than 60 characters'], 30 | }, 31 | email: { 32 | type: String, 33 | required: [true, 'Please provide your email address'], 34 | unique: true, 35 | }, 36 | password: { 37 | type: String, 38 | required: false, 39 | }, 40 | image: { 41 | type: String, 42 | }, 43 | role: { 44 | type: String, 45 | enum: ['user', 'admin', 'super-admin'], 46 | default: 'user', 47 | }, 48 | adminPermissions: { 49 | type: AdminPermissionsSchema, 50 | default: () => ({ 51 | canUpdateUserInfo: true, 52 | canDeleteUsers: false, 53 | canPromoteToAdmin: false, 54 | canDemoteAdmins: false, 55 | }), 56 | }, 57 | }, 58 | { 59 | timestamps: true, 60 | } 61 | ); 62 | export default mongoose.models.User || mongoose.model('User', UserSchema); 63 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import ToDoList from '@/features/todos/ToDoList'; 2 | import TodoStats from '@/features/todos/TodoStats'; 3 | import { auth } from '@/app/api/auth/[...nextauth]/route'; 4 | export default async function HomePage() { 5 | const session = await auth(); 6 | return ( 7 |
8 |
9 |
10 |

11 | Manage Your Tasks 12 |

13 |

14 | Stay organized and boost your productivity 15 |

16 |
17 | {session ? ( 18 | <> 19 | 20 | 21 | 22 | ) : ( 23 |
24 |
25 |

26 | Sign in to manage your tasks 27 |

28 |

29 | Create an account or sign in to start tracking your tasks and boost your 30 | productivity. 31 |

32 |
33 |
34 | )} 35 |
36 |

37 | © {new Date().getFullYear()} TaskMaster. All rights reserved. 38 |

39 |
40 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/app/api/stats/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { connectToDatabase } from '@/lib/mongodb'; 3 | import TodoModel from '@/models/Todo'; 4 | import UserModel from '@/models/User'; 5 | import { auth } from '@/app/api/auth/[...nextauth]/route'; 6 | export async function GET() { 7 | try { 8 | const session = await auth(); 9 | if (!session || !session.user) { 10 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 11 | } 12 | const userId = session.user.id; 13 | const userRole = session.user.role; 14 | await connectToDatabase(); 15 | const userTodos = await TodoModel.find({ userId }); 16 | const activeTasks = userTodos.filter(todo => !todo.completed).length; 17 | const completedTasks = userTodos.filter(todo => todo.completed).length; 18 | const pendingTasks = activeTasks; 19 | const response: { 20 | userStats: { 21 | total: number; 22 | active: number; 23 | completed: number; 24 | pending: number; 25 | }; 26 | adminStats: { 27 | totalUsers: number; 28 | totalTasks: number; 29 | systemStatus: string; 30 | } | null; 31 | } = { 32 | userStats: { 33 | total: userTodos.length, 34 | active: activeTasks, 35 | completed: completedTasks, 36 | pending: pendingTasks, 37 | }, 38 | adminStats: null, 39 | }; 40 | if (userRole === 'admin' || userRole === 'super-admin') { 41 | const totalUsers = await UserModel.countDocuments(); 42 | const totalTasks = await TodoModel.countDocuments(); 43 | response.adminStats = { 44 | totalUsers, 45 | totalTasks, 46 | systemStatus: 'Active', 47 | }; 48 | } 49 | return NextResponse.json(response); 50 | } catch (_error) { 51 | return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/features/todos/api.ts: -------------------------------------------------------------------------------- 1 | import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; 2 | import { ITodo } from '@/types/todo'; 3 | export interface UserStats { 4 | total: number; 5 | active: number; 6 | completed: number; 7 | pending: number; 8 | } 9 | export interface AdminStats { 10 | totalUsers: number; 11 | totalTasks: number; 12 | systemStatus: string; 13 | } 14 | export interface DashboardStats { 15 | userStats: UserStats; 16 | adminStats: AdminStats | null; 17 | } 18 | export const todosApi = createApi({ 19 | reducerPath: 'todosApi', 20 | baseQuery: fetchBaseQuery({ 21 | baseUrl: '/api/', 22 | credentials: 'include', 23 | }), 24 | tagTypes: ['Todos'], 25 | endpoints: builder => ({ 26 | getStats: builder.query({ 27 | query: () => 'stats', 28 | providesTags: ['Todos'], 29 | }), 30 | getTodos: builder.query({ 31 | query: () => 'todos', 32 | providesTags: ['Todos'], 33 | }), 34 | addTodo: builder.mutation>({ 35 | query: todo => ({ 36 | url: 'todos', 37 | method: 'POST', 38 | body: todo, 39 | }), 40 | invalidatesTags: ['Todos'], 41 | }), 42 | deleteTodo: builder.mutation({ 43 | query: id => ({ 44 | url: `todos/${id}`, 45 | method: 'DELETE', 46 | }), 47 | invalidatesTags: ['Todos'], 48 | }), 49 | updateTodo: builder.mutation({ 50 | query: todo => ({ 51 | url: `todos/${todo.id}`, 52 | method: 'PUT', 53 | body: { 54 | title: todo.title, 55 | completed: todo.completed, 56 | }, 57 | }), 58 | invalidatesTags: ['Todos'], 59 | }), 60 | }), 61 | }); 62 | export const { 63 | useGetStatsQuery, 64 | useGetTodosQuery, 65 | useAddTodoMutation, 66 | useDeleteTodoMutation, 67 | useUpdateTodoMutation, 68 | } = todosApi; 69 | -------------------------------------------------------------------------------- /src/components/ThemeToggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useTheme } from 'next-themes'; 3 | import { useEffect, useState } from 'react'; 4 | export default function ThemeToggle() { 5 | const [mounted, setMounted] = useState(false); 6 | const { theme, setTheme } = useTheme(); 7 | useEffect(() => { 8 | setMounted(true); 9 | }, []); 10 | if (!mounted) { 11 | return null; 12 | } 13 | return ( 14 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/app/api/todos/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { connectToDatabase } from '@/lib/mongodb'; 3 | import TodoModel from '@/models/Todo'; 4 | import { auth } from '@/app/api/auth/[...nextauth]/route'; 5 | import { showToast } from '@/lib/toast'; 6 | export async function GET() { 7 | try { 8 | const session = await auth(); 9 | if (!session || !session.user) { 10 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 11 | } 12 | const userId = session.user.id; 13 | await connectToDatabase(); 14 | const todos = await TodoModel.find({ userId }).sort({ createdAt: -1 }); 15 | const transformedTodos = todos.map(todo => ({ 16 | id: todo._id.toString(), 17 | title: todo.title, 18 | completed: todo.completed, 19 | })); 20 | return NextResponse.json(transformedTodos, { status: 200 }); 21 | } catch (_error) { 22 | showToast.info('Error fetching todos', _error); 23 | return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); 24 | } 25 | } 26 | export async function POST(req: NextRequest) { 27 | try { 28 | const session = await auth(); 29 | if (!session || !session.user) { 30 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 31 | } 32 | const userId = session.user.id; 33 | const body = await req.json(); 34 | if (!body.title) { 35 | return NextResponse.json({ error: 'Title is required' }, { status: 400 }); 36 | } 37 | await connectToDatabase(); 38 | const todo = await TodoModel.create({ 39 | title: body.title, 40 | completed: body.completed || false, 41 | userId, 42 | }); 43 | const transformedTodo = { 44 | id: todo._id.toString(), 45 | title: todo.title, 46 | completed: todo.completed, 47 | }; 48 | return NextResponse.json(transformedTodo, { status: 201 }); 49 | } catch (_error) { 50 | showToast.info('Error creating todo', _error); 51 | return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/lib/permissions/types.ts: -------------------------------------------------------------------------------- 1 | import { UserRole, AdminPermissions } from '@/models/user.interface'; 2 | export type Permission = 3 | | 'users:view' 4 | | 'users:create' 5 | | 'users:update' 6 | | 'users:delete' 7 | | 'users:promote' 8 | | 'roles:manage' 9 | | 'todos:view' 10 | | 'todos:create' 11 | | 'todos:update' 12 | | 'todos:delete' 13 | | 'todos:view-all' 14 | | 'todos:manage-all' 15 | | 'dashboard:view' 16 | | 'admin:access' 17 | | 'super-admin:access'; 18 | export const DEFAULT_ROLE_PERMISSIONS: Record = { 19 | user: ['todos:view', 'todos:create', 'todos:update', 'todos:delete', 'dashboard:view'], 20 | admin: [ 21 | 'todos:view', 22 | 'todos:create', 23 | 'todos:update', 24 | 'todos:delete', 25 | 'todos:view-all', 26 | 'todos:manage-all', 27 | 'dashboard:view', 28 | 'admin:access', 29 | 'users:view', 30 | ], 31 | 'super-admin': [ 32 | 'todos:view', 33 | 'todos:create', 34 | 'todos:update', 35 | 'todos:delete', 36 | 'todos:view-all', 37 | 'todos:manage-all', 38 | 'dashboard:view', 39 | 'admin:access', 40 | 'super-admin:access', 41 | 'users:view', 42 | 'users:create', 43 | 'users:update', 44 | 'users:delete', 45 | 'users:promote', 46 | 'roles:manage', 47 | ], 48 | }; 49 | export function getAdminPermissions(adminPermissions?: AdminPermissions): Permission[] { 50 | if (!adminPermissions) return []; 51 | const permissions: Permission[] = []; 52 | if (adminPermissions.canUpdateUserInfo) { 53 | permissions.push('users:update'); 54 | } 55 | if (adminPermissions.canDeleteUsers) { 56 | permissions.push('users:delete'); 57 | } 58 | return permissions; 59 | } 60 | export function getAllUserPermissions( 61 | role: UserRole | undefined, 62 | adminPermissions?: AdminPermissions 63 | ): Permission[] { 64 | if (!role) return []; 65 | const basePermissions = DEFAULT_ROLE_PERMISSIONS[role] || []; 66 | const adminSpecificPermissions = role === 'admin' ? getAdminPermissions(adminPermissions) : []; 67 | return [...new Set([...basePermissions, ...adminSpecificPermissions])]; 68 | } 69 | -------------------------------------------------------------------------------- /src/lib/permissions.ts: -------------------------------------------------------------------------------- 1 | import { UserRole, AdminPermissions } from '@/models/user.interface'; 2 | export const RolePermissions = { 3 | canManageAllTodos: (role: UserRole | undefined): boolean => { 4 | return role === 'super-admin'; 5 | }, 6 | canViewUsers: (role: UserRole | undefined): boolean => { 7 | return role === 'admin' || role === 'super-admin'; 8 | }, 9 | canDeleteRegularUsers: ( 10 | role: UserRole | undefined, 11 | adminPermissions?: AdminPermissions 12 | ): boolean => { 13 | if (role === 'super-admin') return true; 14 | if (role === 'admin' && adminPermissions) { 15 | return adminPermissions.canDeleteUsers || false; 16 | } 17 | return false; 18 | }, 19 | canDeleteAdminUsers: (role: UserRole | undefined): boolean => { 20 | return role === 'super-admin'; 21 | }, 22 | canPromoteToAdmin: (role: UserRole | undefined, adminPermissions?: AdminPermissions): boolean => { 23 | if (role === 'super-admin') return true; 24 | if (role === 'admin' && adminPermissions) { 25 | return adminPermissions.canPromoteToAdmin || false; 26 | } 27 | return false; 28 | }, 29 | canPromoteToSuperAdmin: (role: UserRole | undefined): boolean => { 30 | return role === 'super-admin'; 31 | }, 32 | canDemoteAdmins: (role: UserRole | undefined, adminPermissions?: AdminPermissions): boolean => { 33 | if (role === 'super-admin') return true; 34 | if (role === 'admin' && adminPermissions) { 35 | return adminPermissions.canDemoteAdmins || false; 36 | } 37 | return false; 38 | }, 39 | canUpdateUserInfo: (role: UserRole | undefined, adminPermissions?: AdminPermissions): boolean => { 40 | if (role === 'super-admin') return true; 41 | if (role === 'admin' && adminPermissions) { 42 | return adminPermissions.canUpdateUserInfo || false; 43 | } 44 | return false; 45 | }, 46 | canDeleteUsers: (role: UserRole | undefined, adminPermissions?: AdminPermissions): boolean => { 47 | if (role === 'super-admin') return true; 48 | if (role === 'admin' && adminPermissions) { 49 | return adminPermissions.canDeleteUsers || false; 50 | } 51 | return false; 52 | }, 53 | }; 54 | -------------------------------------------------------------------------------- /EditableTodoItem.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useState, KeyboardEvent } from 'react'; 3 | import { ITodo } from '@/types/todo'; 4 | import TodoItem from './TodoItem'; 5 | interface EditableTodoItemProps { 6 | todo: ITodo; 7 | onToggleComplete: (todo: ITodo) => void; 8 | onDelete: (id: string | number) => void; 9 | onUpdate: (todo: ITodo, newTitle: string) => void; 10 | } 11 | export default function EditableTodoItem({ 12 | todo, 13 | onToggleComplete, 14 | onDelete, 15 | onUpdate, 16 | }: EditableTodoItemProps) { 17 | const [isEditing, setIsEditing] = useState(false); 18 | const [editInput, setEditInput] = useState(todo.title); 19 | const handleEdit = () => { 20 | setIsEditing(true); 21 | setEditInput(todo.title); 22 | }; 23 | const handleCancel = () => { 24 | setIsEditing(false); 25 | setEditInput(todo.title); 26 | }; 27 | const handleSave = () => { 28 | if (editInput.trim()) { 29 | onUpdate(todo, editInput); 30 | setIsEditing(false); 31 | } 32 | }; 33 | const handleKeyDown = (e: KeyboardEvent) => { 34 | if (e.key === 'Enter') { 35 | handleSave(); 36 | } else if (e.key === 'Escape') { 37 | handleCancel(); 38 | } 39 | }; 40 | if (isEditing) { 41 | return ( 42 |
43 | setEditInput(e.target.value)} 47 | onKeyDown={handleKeyDown} 48 | className="flex-1 p-2 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all duration-200" 49 | autoFocus 50 | /> 51 |
52 | 58 | 64 |
65 |
66 | ); 67 | } 68 | return ( 69 | 75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /src/components/GoogleAuthButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { signIn } from 'next-auth/react'; 3 | import { useState } from 'react'; 4 | type GoogleAuthButtonProps = { 5 | callbackUrl?: string; 6 | className?: string; 7 | }; 8 | export default function GoogleAuthButton({ 9 | callbackUrl = '/', 10 | className = '', 11 | }: GoogleAuthButtonProps) { 12 | const [isLoading, setIsLoading] = useState(false); 13 | const handleGoogleSignIn = async () => { 14 | setIsLoading(true); 15 | try { 16 | await signIn('google', { callbackUrl }); 17 | } catch { 18 | } finally { 19 | setIsLoading(false); 20 | } 21 | }; 22 | return ( 23 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/components/todos/EditableTodoItem.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | 'use client'; 3 | import { ITodo } from '@/types/todo'; 4 | import { useState, KeyboardEvent } from 'react'; 5 | import TodoItem from './TodoItem'; 6 | interface EditableTodoItemProps { 7 | todo: ITodo; 8 | onToggleComplete: () => void; 9 | onDelete: () => void; 10 | onUpdate: (todo: ITodo, newTitle: string) => void; 11 | } 12 | export default function EditableTodoItem({ 13 | todo, 14 | onToggleComplete, 15 | onDelete, 16 | onUpdate, 17 | }: EditableTodoItemProps) { 18 | const [isEditing, setIsEditing] = useState(false); 19 | const [editInput, setEditInput] = useState(todo.title); 20 | const handleEdit = () => { 21 | setIsEditing(true); 22 | setEditInput(todo.title); 23 | }; 24 | const handleCancel = () => { 25 | setIsEditing(false); 26 | setEditInput(todo.title); 27 | }; 28 | const handleSave = () => { 29 | if (editInput.trim()) { 30 | onUpdate(todo, editInput); 31 | setIsEditing(false); 32 | } 33 | }; 34 | const handleKeyDown = (e: KeyboardEvent) => { 35 | if (e.key === 'Enter') { 36 | handleSave(); 37 | } else if (e.key === 'Escape') { 38 | handleCancel(); 39 | } 40 | }; 41 | if (isEditing) { 42 | return ( 43 |
44 | setEditInput(e.target.value)} 48 | onKeyDown={handleKeyDown} 49 | className="flex-1 p-2 border border-gray-300 dark:border-gray-700 rounded-md bg-white dark:bg-gray-700 text-gray-800 dark:text-gray-200 focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all duration-200" 50 | autoFocus 51 | /> 52 |
53 | 59 | 65 |
66 |
67 | ); 68 | } 69 | return ( 70 | 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /src/app/auth/debug/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useEffect } from 'react'; 3 | import Link from 'next/link'; 4 | import { useSession } from 'next-auth/react'; 5 | import { showToast } from '@/lib/toast'; 6 | export default function AuthDebug() { 7 | const { data: session, status } = useSession(); 8 | useEffect(() => { 9 | showToast.info(`Auth debug - Session status: ${status}`, null); 10 | if (session) { 11 | showToast.info('Session data available', null); 12 | } 13 | }, [session, status]); 14 | return ( 15 |
16 |
17 |

18 | Auth Debug Page 19 |

20 |
21 |
22 |
23 |
24 |

25 | Status: {status} 26 |

27 |

28 | User: {session?.user?.name || 'Not signed in'} 29 |

30 |

31 | Email: {session?.user?.email || 'N/A'} 32 |

33 |
34 |
35 | 42 | Sign In 43 | 44 | 51 | Sign Out 52 | 53 | 57 | Return to home page 58 | 59 |
60 |
61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/app/auth/error/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useEffect, useState } from 'react'; 3 | import Link from 'next/link'; 4 | import { useSearchParams } from 'next/navigation'; 5 | import { showToast } from '@/lib/toast'; 6 | export default function AuthError() { 7 | const searchParams = useSearchParams(); 8 | const [errorMessage, setErrorMessage] = useState( 9 | 'An error occurred during authentication' 10 | ); 11 | useEffect(() => { 12 | const error = searchParams.get('error'); 13 | if (error) { 14 | showToast.error(`Authentication error: ${error}`); 15 | } 16 | 17 | if (error === 'CredentialsSignin') { 18 | setErrorMessage('Invalid email or password'); 19 | } else if (error === 'OAuthAccountNotLinked') { 20 | setErrorMessage('The email is already used with another sign-in method'); 21 | } else if (error === 'EmailSignin') { 22 | setErrorMessage('The email could not be sent'); 23 | } else if (error === 'Configuration') { 24 | setErrorMessage('There is a problem with the server configuration'); 25 | } else if (error === 'AccessDenied') { 26 | setErrorMessage('You do not have access to this resource'); 27 | } else if (error) { 28 | setErrorMessage(`Authentication error: ${error}`); 29 | } 30 | }, [searchParams]); 31 | return ( 32 |
33 |
34 |

35 | Authentication Error 36 |

37 |
38 |
39 |
40 |
41 |

{errorMessage}

42 |
43 |
44 | 51 | Try signing in again 52 | 53 | 57 | Return to home page 58 | 59 |
60 |
61 |
62 |
63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | :root { 4 | --background: #f9fafb; 5 | --foreground: #111827; 6 | --primary: #4f46e5; 7 | --primary-hover: #4338ca; 8 | --primary-light: #eef2ff; 9 | --success: #10b981; 10 | --success-light: #ecfdf5; 11 | --danger: #ef4444; 12 | --danger-light: #fef2f2; 13 | --card-bg: #ffffff; 14 | --card-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); 15 | --input-bg: #ffffff; 16 | --input-border: #d1d5db; 17 | } 18 | 19 | :root { 20 | --color-background: var(--background); 21 | --color-foreground: var(--foreground); 22 | --font-sans: 23 | system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 24 | sans-serif; 25 | --font-mono: 26 | ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', 27 | monospace; 28 | } 29 | 30 | @media (prefers-color-scheme: dark) { 31 | :root { 32 | --background: #111827; 33 | --foreground: #f9fafb; 34 | --primary: #6366f1; 35 | --primary-hover: #4f46e5; 36 | --primary-light: #1e1b4b; 37 | --success: #059669; 38 | --success-light: #064e3b; 39 | --danger: #dc2626; 40 | --danger-light: #7f1d1d; 41 | --card-bg: #1f2937; 42 | --card-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3), 0 4px 6px -2px rgba(0, 0, 0, 0.2); 43 | --input-bg: #374151; 44 | --input-border: #4b5563; 45 | } 46 | } 47 | 48 | body { 49 | background: var(--background); 50 | color: var(--foreground); 51 | font-family: 52 | system-ui, 53 | -apple-system, 54 | BlinkMacSystemFont, 55 | 'Segoe UI', 56 | Roboto, 57 | 'Helvetica Neue', 58 | Arial, 59 | sans-serif; 60 | } 61 | 62 | input[type='checkbox'] { 63 | cursor: pointer; 64 | transition: all 0.2s ease; 65 | } 66 | 67 | button { 68 | transition: all 0.2s ease; 69 | } 70 | 71 | ::-webkit-scrollbar { 72 | width: 8px; 73 | height: 8px; 74 | } 75 | 76 | ::-webkit-scrollbar-track { 77 | background: transparent; 78 | } 79 | 80 | ::-webkit-scrollbar-thumb { 81 | background: #d1d5db; 82 | border-radius: 4px; 83 | } 84 | 85 | .dark ::-webkit-scrollbar-thumb { 86 | background: #4b5563; 87 | } 88 | 89 | ::-webkit-scrollbar-thumb:hover { 90 | background: #9ca3af; 91 | } 92 | 93 | .dark ::-webkit-scrollbar-thumb:hover { 94 | background: #6b7280; 95 | } 96 | 97 | ::selection { 98 | background-color: var(--primary); 99 | color: white; 100 | } 101 | 102 | @keyframes fadeIn { 103 | from { 104 | opacity: 0; 105 | } 106 | to { 107 | opacity: 1; 108 | } 109 | } 110 | 111 | .animate-fade-in { 112 | animation: fadeIn 0.3s ease-in-out; 113 | } 114 | 115 | .transition-all { 116 | transition-property: all; 117 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); 118 | transition-duration: 150ms; 119 | } 120 | 121 | .dark .bg-gray-750 { 122 | background-color: #283548; 123 | } 124 | -------------------------------------------------------------------------------- /src/components/todos/TodoItem.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | 'use client'; 3 | import { memo } from 'react'; 4 | import { ITodo } from '@/types/todo'; 5 | interface TodoItemProps { 6 | todo: ITodo; 7 | onToggleComplete: (todo: ITodo) => void; 8 | onDelete: (id: string | number) => void; 9 | onEdit: (todo: ITodo) => void; 10 | } 11 | const TodoItem = memo(function TodoItem({ 12 | todo, 13 | onToggleComplete, 14 | onDelete, 15 | onEdit, 16 | }: TodoItemProps) { 17 | return ( 18 |
19 |
20 | onToggleComplete(todo)} 24 | className="h-5 w-5 text-indigo-600 dark:text-indigo-500 rounded border-gray-300 dark:border-gray-600 focus:ring-indigo-500 dark:focus:ring-indigo-400 transition-all duration-200" 25 | /> 26 | 33 | {todo.title} 34 | 35 |
36 |
37 | 57 | 77 |
78 |
79 | ); 80 | }); 81 | export default TodoItem; 82 | -------------------------------------------------------------------------------- /src/app/api/todos/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { connectToDatabase } from '@/lib/mongodb'; 3 | import TodoModel from '@/models/Todo'; 4 | import { auth } from '@/app/api/auth/[...nextauth]/route'; 5 | import mongoose from 'mongoose'; 6 | interface Params { 7 | params: { 8 | id: string; 9 | }; 10 | } 11 | export async function GET(req: NextRequest, { params }: Params) { 12 | try { 13 | const session = await auth(); 14 | if (!session || !session.user) { 15 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 16 | } 17 | const userId = session.user.id; 18 | const { id } = params; 19 | if (!mongoose.Types.ObjectId.isValid(id)) { 20 | return NextResponse.json({ error: 'Invalid todo ID' }, { status: 400 }); 21 | } 22 | await connectToDatabase(); 23 | const todo = await TodoModel.findOne({ _id: id, userId }); 24 | if (!todo) { 25 | return NextResponse.json({ error: 'Todo not found' }, { status: 404 }); 26 | } 27 | const transformedTodo = { 28 | id: todo._id.toString(), 29 | title: todo.title, 30 | completed: todo.completed, 31 | }; 32 | return NextResponse.json(transformedTodo, { status: 200 }); 33 | } catch (_error) { 34 | return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); 35 | } 36 | } 37 | export async function PUT(req: NextRequest, { params }: Params) { 38 | try { 39 | const session = await auth(); 40 | if (!session || !session.user) { 41 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 42 | } 43 | const userId = session.user.id; 44 | const { id } = params; 45 | const body = await req.json(); 46 | if (!mongoose.Types.ObjectId.isValid(id)) { 47 | return NextResponse.json({ error: 'Invalid todo ID' }, { status: 400 }); 48 | } 49 | await connectToDatabase(); 50 | const todo = await TodoModel.findOneAndUpdate( 51 | { _id: id, userId }, 52 | { title: body.title, completed: body.completed }, 53 | { new: true } 54 | ); 55 | if (!todo) { 56 | return NextResponse.json({ error: 'Todo not found' }, { status: 404 }); 57 | } 58 | const transformedTodo = { 59 | id: todo._id.toString(), 60 | title: todo.title, 61 | completed: todo.completed, 62 | }; 63 | return NextResponse.json(transformedTodo, { status: 200 }); 64 | } catch (_error) { 65 | return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); 66 | } 67 | } 68 | export async function DELETE(req: NextRequest, { params }: Params) { 69 | try { 70 | const session = await auth(); 71 | if (!session || !session.user) { 72 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 73 | } 74 | const userId = session.user.id; 75 | const { id } = params; 76 | if (!mongoose.Types.ObjectId.isValid(id)) { 77 | return NextResponse.json({ error: 'Invalid todo ID' }, { status: 400 }); 78 | } 79 | await connectToDatabase(); 80 | const todo = await TodoModel.findOneAndDelete({ _id: id, userId }); 81 | if (!todo) { 82 | return NextResponse.json({ error: 'Todo not found' }, { status: 404 }); 83 | } 84 | return NextResponse.json({ message: 'Todo deleted successfully' }, { status: 200 }); 85 | } catch (_error) { 86 | return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Project Overview 2 | This is a Todo application built with Next.js that uses both Redux Toolkit (RTK) Query for API calls and Zustand for local state management. The application allows users to create, read, update, and delete todo items. 3 | 4 | Tech Stack 5 | Next.js: React framework for building the frontend 6 | Redux Toolkit (RTK) Query: For API data fetching, caching, and state management 7 | Zustand: For local UI state management 8 | JSON Server: Backend mock API running on port 4000 9 | TailwindCSS: For styling 10 | Code Structure & Workflow 11 | 12 | 1. Data Structure 13 | The application uses a simple Todo type defined in todo.ts: 14 | 15 | export interface ITodo {  id: number  title: string  completed: boolean} 2. State Management 16 | The project uses a hybrid state management approach: 17 | 18 | RTK Query (Redux Toolkit) 19 | In api.ts, RTK Query is used for API calls: 20 | 21 | getTodos: Fetches all todos from the server 22 | addTodo: Creates a new todo 23 | deleteTodo: Removes a todo by ID 24 | updateTodo: Updates an existing todo 25 | The Redux store is configured in index.ts and includes the todos API reducer. 26 | 27 | Zustand Store 28 | In store.ts, a Zustand store manages the local UI state: 29 | 30 | Tracks the input value for creating new todos 31 | Provides methods to set and reset the input value 3. Components 32 | The main component is ToDoList.tsx which: 33 | 34 | Uses the RTK Query hooks to fetch and manipulate todos 35 | Uses Zustand store for managing input state 36 | Implements all CRUD operations: 37 | Add new todos 38 | Toggle todo completion status 39 | Edit todo titles 40 | Delete todos 41 | Handles edit mode UI state with React's useState 4. Application Structure 42 | page.tsx: The main page that renders the TodoList component 43 | layout.tsx: The root layout that wraps the application with the Redux Provider 44 | db.json: The mock database file used by JSON Server 5. Data Flow 45 | The application starts by rendering the ToDoList component in page.tsx 46 | The component fetches todos from the JSON Server using RTK Query's useGetTodosQuery 47 | When a user adds, edits, or deletes a todo: 48 | The corresponding RTK Query mutation hook is called 49 | The mutation updates the server data via JSON Server 50 | RTK Query automatically refetches the data and updates the UI 6. Development Workflow 51 | To run the application: 52 | 53 | Start the JSON Server: npm run json-server (runs on port 4000) 54 | Start the Next.js dev server: npm run dev (with Turbopack) 55 | Key Features 56 | API Data Management: Uses RTK Query for efficient API calls with automatic caching 57 | Local UI State: Uses Zustand for simple local state management 58 | CRUD Operations: Full create, read, update, delete functionality 59 | Responsive UI: Clean UI with TailwindCSS styling 60 | Edit Mode: Inline editing of todo items 61 | Authentication: NextAuth.js with Google OAuth and credential sign-in options 62 | 63 | ## Setting Up Google OAuth 64 | 65 | To set up Google authentication: 66 | 67 | 1. Go to the [Google Cloud Console](https://console.cloud.google.com/) 68 | 2. Create a new project or select an existing one 69 | 3. Navigate to "APIs & Services" > "Credentials" 70 | 4. Click "Create Credentials" and select "OAuth client ID" 71 | 5. Set the application type to "Web application" 72 | 6. Add your authorized origins (e.g., `http://localhost:3000`) 73 | 7. Add your authorized redirect URIs (e.g., `http://localhost:3000/api/auth/callback/google`) 74 | 8. Click "Create" and note your Client ID and Client Secret 75 | 9. Copy `.env.local.example` to `.env.local` and add your Google credentials: 76 | 77 | ``` 78 | GOOGLE_CLIENT_ID=your_client_id_here 79 | GOOGLE_CLIENT_SECRET=your_client_secret_here 80 | NEXTAUTH_SECRET=generate_a_secure_random_string 81 | NEXTAUTH_URL=http://localhost:3000 82 | ``` 83 | 84 | For production, make sure to update the authorized origins and redirect URIs to your production domain. 85 | -------------------------------------------------------------------------------- /src/app/api/users/profile/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { connectToDatabase } from '@/lib/mongodb'; 3 | import UserModel from '@/models/User'; 4 | import { auth } from '@/app/api/auth/[...nextauth]/route'; 5 | import { hashPassword } from '@/lib/auth'; 6 | export async function GET() { 7 | try { 8 | const session = await auth(); 9 | if (!session || !session.user) { 10 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 11 | } 12 | await connectToDatabase(); 13 | const user = await UserModel.findById(session.user.id).select('-password'); 14 | if (!user) { 15 | return NextResponse.json({ error: 'User not found' }, { status: 404 }); 16 | } 17 | return NextResponse.json(user); 18 | } catch (_error) { 19 | const errorMessage = error instanceof Error ? error.message : 'Failed to fetch user profile'; 20 | return NextResponse.json({ error: errorMessage }, { status: 500 }); 21 | } 22 | } 23 | export async function PUT(req: NextRequest) { 24 | try { 25 | const session = await auth(); 26 | if (!session || !session.user) { 27 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 28 | } 29 | await connectToDatabase(); 30 | const currentUser = await UserModel.findById(session.user.id); 31 | if (!currentUser) { 32 | return NextResponse.json({ error: 'User not found' }, { status: 404 }); 33 | } 34 | const { name, email, currentPassword, newPassword } = (await req.json()) as { 35 | name?: string; 36 | email?: string; 37 | currentPassword?: string; 38 | newPassword?: string; 39 | }; 40 | const updateData: { 41 | name?: string; 42 | email?: string; 43 | password?: string; 44 | } = {}; 45 | if (name) { 46 | if (name.length > 60) { 47 | return NextResponse.json( 48 | { error: 'Name cannot be more than 60 characters' }, 49 | { status: 400 } 50 | ); 51 | } 52 | updateData.name = name; 53 | } 54 | if (email && email !== currentUser.email) { 55 | const existingUser = await UserModel.findOne({ email }); 56 | if (existingUser && existingUser._id.toString() !== session.user.id) { 57 | return NextResponse.json({ error: 'Email is already in use' }, { status: 409 }); 58 | } 59 | updateData.email = email; 60 | } 61 | if (newPassword) { 62 | if (!currentPassword) { 63 | return NextResponse.json( 64 | { error: 'Current password is required to set a new password' }, 65 | { status: 400 } 66 | ); 67 | } 68 | const { verifyPassword } = await import('@/lib/auth'); 69 | const isCurrentPasswordValid = await verifyPassword(currentPassword, currentUser.password); 70 | if (!isCurrentPasswordValid) { 71 | return NextResponse.json({ error: 'Current password is incorrect' }, { status: 400 }); 72 | } 73 | try { 74 | updateData.password = await hashPassword(newPassword); 75 | } catch (_error) { 76 | const errorMessage = error instanceof Error ? error.message : 'Invalid password'; 77 | return NextResponse.json({ error: errorMessage }, { status: 400 }); 78 | } 79 | } 80 | if (Object.keys(updateData).length === 0) { 81 | return NextResponse.json({ message: 'No changes to update' }, { status: 200 }); 82 | } 83 | const updatedUser = await UserModel.findByIdAndUpdate(session.user.id, updateData, { 84 | new: true, 85 | select: '-password', 86 | }); 87 | return NextResponse.json({ 88 | message: 'Profile updated successfully', 89 | user: updatedUser, 90 | }); 91 | } catch (_error) { 92 | const errorMessage = error instanceof Error ? error.message : 'Failed to update profile'; 93 | return NextResponse.json({ error: errorMessage }, { status: 500 }); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/features/todos/TodoStats.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useGetTodosQuery } from './api'; 3 | export default function TodoStats() { 4 | const { data } = useGetTodosQuery(); 5 | if (!data) return null; 6 | const totalTasks = data.length; 7 | const completedTasks = data.filter(todo => todo.completed).length; 8 | const pendingTasks = totalTasks - completedTasks; 9 | const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; 10 | return ( 11 |
12 |
13 |
14 | 21 | 27 | 28 |
29 |
30 |

Total Tasks

31 |

{totalTasks}

32 |
33 |
34 |
38 |
39 | 46 | 47 | 48 |
49 |
50 |

Completed

51 |

{completedTasks}

52 |
53 |
54 |
58 |
59 | 66 | 72 | 73 |
74 |
75 |

Pending

76 |

{pendingTasks}

77 |
78 |
79 |
83 |

Completion Rate

84 |
85 |
89 |
90 |

91 | {completionRate}% 92 |

93 |
94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/app/api/admin/todos/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { connectToDatabase } from '@/lib/mongodb'; 3 | import TodoModel from '@/models/Todo'; 4 | import { auth } from '@/app/api/auth/[...nextauth]/route'; 5 | import { RolePermissions } from '@/lib/permissions'; 6 | import { showToast } from '@/lib/toast'; 7 | 8 | // GET all todos (super-admin only) 9 | export async function GET(req: NextRequest) { 10 | try { 11 | const session = await auth(); 12 | if (!session?.user) { 13 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 14 | } 15 | 16 | const role = session.user.role; 17 | 18 | // Check if user has permissions to manage all todos 19 | if (!RolePermissions.canManageAllTodos(role)) { 20 | return NextResponse.json({ error: 'Forbidden: Insufficient permissions' }, { status: 403 }); 21 | } 22 | 23 | // Connect to database and fetch all todos with user information 24 | await connectToDatabase(); 25 | 26 | // Get query parameters for pagination and filtering 27 | const searchParams = req.nextUrl.searchParams; 28 | const page = parseInt(searchParams.get('page') || '1', 10); 29 | const limit = parseInt(searchParams.get('limit') || '50', 10); 30 | const skip = (page - 1) * limit; 31 | const search = searchParams.get('search') || ''; 32 | 33 | // Create query 34 | let query = {}; 35 | if (search) { 36 | query = { title: { $regex: search, $options: 'i' } }; 37 | } 38 | 39 | // Fetch todos with pagination and populate user information 40 | const todos = await TodoModel.find(query) 41 | .populate('userId', 'name email') 42 | .sort({ createdAt: -1 }) 43 | .skip(skip) 44 | .limit(limit); 45 | 46 | // Count total for pagination 47 | const total = await TodoModel.countDocuments(query); 48 | 49 | const transformedTodos = todos.map(todo => ({ 50 | id: todo._id.toString(), 51 | title: todo.title, 52 | completed: todo.completed, 53 | createdAt: todo.createdAt, 54 | updatedAt: todo.updatedAt, 55 | user: todo.userId 56 | ? { 57 | id: todo.userId._id, 58 | name: todo.userId.name, 59 | email: todo.userId.email, 60 | } 61 | : null, 62 | })); 63 | 64 | return NextResponse.json( 65 | { 66 | todos: transformedTodos, 67 | pagination: { 68 | total, 69 | page, 70 | limit, 71 | pages: Math.ceil(total / limit), 72 | }, 73 | }, 74 | { status: 200 } 75 | ); 76 | } catch (_error) { 77 | showToast.info('Error updating todo', _error); 78 | return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); 79 | } 80 | } 81 | 82 | // Update a todo (super-admin only) 83 | export async function PUT(req: NextRequest) { 84 | try { 85 | const session = await auth(); 86 | if (!session?.user) { 87 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 88 | } 89 | 90 | const role = session.user.role; 91 | 92 | // Check if user has permissions to manage all todos 93 | if (!RolePermissions.canManageAllTodos(role)) { 94 | return NextResponse.json({ error: 'Forbidden: Insufficient permissions' }, { status: 403 }); 95 | } 96 | 97 | const { id, title, completed } = await req.json(); 98 | 99 | if (!id) { 100 | return NextResponse.json({ error: 'Todo ID is required' }, { status: 400 }); 101 | } 102 | 103 | await connectToDatabase(); 104 | 105 | const todo = await TodoModel.findById(id); 106 | if (!todo) { 107 | return NextResponse.json({ error: 'Todo not found' }, { status: 404 }); 108 | } 109 | 110 | // Update fields if provided 111 | if (title !== undefined) todo.title = title; 112 | if (completed !== undefined) todo.completed = completed; 113 | 114 | await todo.save(); 115 | 116 | return NextResponse.json( 117 | { 118 | id: todo._id.toString(), 119 | title: todo.title, 120 | completed: todo.completed, 121 | updatedAt: todo.updatedAt, 122 | }, 123 | { status: 200 } 124 | ); 125 | } catch (_error) { 126 | showToast.info('Error updating todo', _error); 127 | return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/app/api/admin/todos/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { connectToDatabase } from '@/lib/mongodb'; 3 | import TodoModel from '@/models/Todo'; 4 | import { auth } from '@/app/api/auth/[...nextauth]/route'; 5 | import { RolePermissions } from '@/lib/permissions'; 6 | import { showToast } from '@/lib/toast'; 7 | 8 | // GET, DELETE, UPDATE a specific todo by ID (super-admin only) 9 | export async function GET(req: NextRequest, { params }: { params: { id: string } }) { 10 | try { 11 | const session = await auth(); 12 | if (!session?.user) { 13 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 14 | } 15 | 16 | const role = session.user.role; 17 | 18 | // Check if user has permissions to manage all todos 19 | if (!RolePermissions.canManageAllTodos(role)) { 20 | return NextResponse.json({ error: 'Forbidden: Insufficient permissions' }, { status: 403 }); 21 | } 22 | 23 | const id = params.id; 24 | 25 | await connectToDatabase(); 26 | 27 | const todo = await TodoModel.findById(id).populate('userId', 'name email'); 28 | 29 | if (!todo) { 30 | return NextResponse.json({ error: 'Todo not found' }, { status: 404 }); 31 | } 32 | 33 | return NextResponse.json( 34 | { 35 | id: todo._id.toString(), 36 | title: todo.title, 37 | completed: todo.completed, 38 | createdAt: todo.createdAt, 39 | updatedAt: todo.updatedAt, 40 | user: todo.userId 41 | ? { 42 | id: todo.userId._id, 43 | name: todo.userId.name, 44 | email: todo.userId.email, 45 | } 46 | : null, 47 | }, 48 | { status: 200 } 49 | ); 50 | } catch (error) { 51 | showToast.info('Error updating todo', error); 52 | return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); 53 | } 54 | } 55 | 56 | export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) { 57 | try { 58 | const session = await auth(); 59 | if (!session?.user) { 60 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 61 | } 62 | 63 | const role = session.user.role; 64 | 65 | // Check if user has permissions to manage all todos 66 | if (!RolePermissions.canManageAllTodos(role)) { 67 | return NextResponse.json({ error: 'Forbidden: Insufficient permissions' }, { status: 403 }); 68 | } 69 | 70 | const id = params.id; 71 | 72 | await connectToDatabase(); 73 | 74 | const result = await TodoModel.findByIdAndDelete(id); 75 | 76 | if (!result) { 77 | return NextResponse.json({ error: 'Todo not found' }, { status: 404 }); 78 | } 79 | 80 | return NextResponse.json( 81 | { success: true, message: 'Todo deleted successfully' }, 82 | { status: 200 } 83 | ); 84 | } catch (error) { 85 | showToast.error(`Error deleting todo: ${error}`); 86 | return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); 87 | } 88 | } 89 | 90 | export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) { 91 | try { 92 | const session = await auth(); 93 | if (!session?.user) { 94 | return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); 95 | } 96 | 97 | const role = session.user.role; 98 | 99 | // Check if user has permissions to manage all todos 100 | if (!RolePermissions.canManageAllTodos(role)) { 101 | return NextResponse.json({ error: 'Forbidden: Insufficient permissions' }, { status: 403 }); 102 | } 103 | 104 | const id = params.id; 105 | const { title, completed } = await req.json(); 106 | 107 | await connectToDatabase(); 108 | 109 | const todo = await TodoModel.findById(id); 110 | 111 | if (!todo) { 112 | return NextResponse.json({ error: 'Todo not found' }, { status: 404 }); 113 | } 114 | 115 | // Update fields if provided 116 | if (title !== undefined) todo.title = title; 117 | if (completed !== undefined) todo.completed = completed; 118 | 119 | await todo.save(); 120 | 121 | return NextResponse.json( 122 | { 123 | id: todo._id.toString(), 124 | title: todo.title, 125 | completed: todo.completed, 126 | updatedAt: todo.updatedAt, 127 | }, 128 | { status: 200 } 129 | ); 130 | } catch (error) { 131 | showToast.info('Error updating todo', error); 132 | return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth'; 2 | import { connectToDatabase } from '@/lib/mongodb'; 3 | import CredentialsProvider from 'next-auth/providers/credentials'; 4 | import UserModel from '@/models/User'; 5 | import { verifyPassword } from '@/lib/auth'; 6 | import type { NextAuthConfig } from 'next-auth'; 7 | import GoogleProvider from 'next-auth/providers/google'; 8 | export const authConfig: NextAuthConfig = { 9 | debug: process.env.NODE_ENV !== 'production', 10 | providers: [ 11 | CredentialsProvider({ 12 | id: 'credentials', 13 | name: 'credentials', 14 | credentials: { 15 | email: { label: 'Email', type: 'email' }, 16 | password: { label: 'Password', type: 'password' }, 17 | }, 18 | async authorize(credentials) { 19 | try { 20 | if (!credentials?.email || !credentials?.password) { 21 | return null; 22 | } 23 | await connectToDatabase(); 24 | const user = await UserModel.findOne({ email: credentials.email }); 25 | if (!user) return null; 26 | const passwordString = String(user.get('password')); 27 | if (!passwordString) return null; 28 | const passwordInput = 29 | typeof credentials.password === 'string' ? credentials.password : ''; 30 | const isValid = await verifyPassword(passwordInput, passwordString); 31 | if (!isValid) return null; 32 | const adminPermissions = user.adminPermissions 33 | ? { 34 | canUpdateUserInfo: user.adminPermissions.canUpdateUserInfo, 35 | canDeleteUsers: user.adminPermissions.canDeleteUsers, 36 | } 37 | : undefined; 38 | return { 39 | id: user._id.toString(), 40 | email: user.email, 41 | name: user.name, 42 | image: user.image, 43 | role: user.role || 'user', 44 | adminPermissions: adminPermissions, 45 | }; 46 | } catch { 47 | return null; 48 | } 49 | }, 50 | }), 51 | GoogleProvider({ 52 | clientId: process.env.GOOGLE_CLIENT_ID!, 53 | clientSecret: process.env.GOOGLE_CLIENT_SECRET!, 54 | }), 55 | ], 56 | callbacks: { 57 | async jwt({ token, user }) { 58 | if (user) { 59 | token.id = user.id; 60 | token.role = user.role || 'user'; 61 | if (user.role === 'admin' && user.adminPermissions) { 62 | token.adminPermissions = { 63 | canUpdateUserInfo: user.adminPermissions.canUpdateUserInfo, 64 | canDeleteUsers: user.adminPermissions.canDeleteUsers, 65 | }; 66 | } 67 | } 68 | return token; 69 | }, 70 | async session({ session, token }) { 71 | if (token && session.user) { 72 | session.user.id = token.id as string; 73 | session.user.role = token.role as 'user' | 'admin' | 'super-admin'; 74 | if ( 75 | token.adminPermissions && 76 | typeof token.adminPermissions === 'object' && 77 | 'canUpdateUserInfo' in token.adminPermissions && 78 | 'canDeleteUsers' in token.adminPermissions 79 | ) { 80 | session.user.adminPermissions = { 81 | canUpdateUserInfo: 82 | (token.adminPermissions as { canUpdateUserInfo: boolean }).canUpdateUserInfo === true, 83 | canDeleteUsers: 84 | (token.adminPermissions as { canDeleteUsers: boolean }).canDeleteUsers === true, 85 | }; 86 | } 87 | } 88 | return session; 89 | }, 90 | async signIn({ user, account }) { 91 | if (account?.provider !== 'google') return true; 92 | try { 93 | await connectToDatabase(); 94 | const dbUser = await UserModel.findOneAndUpdate( 95 | { email: user.email }, 96 | { 97 | $setOnInsert: { 98 | name: user.name, 99 | email: user.email, 100 | image: user.image, 101 | role: 'user', 102 | }, 103 | }, 104 | { upsert: true, new: true } 105 | ); 106 | user.id = dbUser._id.toString(); 107 | user.role = dbUser.role; 108 | if (dbUser.role === 'admin' && dbUser.adminPermissions) { 109 | user.adminPermissions = { 110 | canUpdateUserInfo: dbUser.adminPermissions.canUpdateUserInfo, 111 | canDeleteUsers: dbUser.adminPermissions.canDeleteUsers, 112 | }; 113 | } 114 | } catch {} 115 | return true; 116 | }, 117 | }, 118 | pages: { 119 | signIn: '/auth/signin', 120 | error: '/auth/error', 121 | signOut: '/', 122 | }, 123 | session: { 124 | strategy: 'jwt', 125 | maxAge: 30 * 24 * 60 * 60, 126 | }, 127 | cookies: { 128 | sessionToken: { 129 | name: `next-auth.session-token`, 130 | options: { 131 | httpOnly: true, 132 | sameSite: 'lax', 133 | path: '/', 134 | secure: process.env.NODE_ENV === 'production', 135 | }, 136 | }, 137 | }, 138 | secret: process.env.NEXTAUTH_SECRET, 139 | }; 140 | export const { handlers, auth, signIn, signOut } = NextAuth(authConfig); 141 | export const { GET, POST } = handlers; 142 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useState } from 'react'; 3 | import { signOut, useSession } from 'next-auth/react'; 4 | import Link from 'next/link'; 5 | import ThemeToggle from './ThemeToggle'; 6 | export default function Header() { 7 | const { data: session, status } = useSession(); 8 | const isLoading = status === 'loading'; 9 | const [dropdownOpen, setDropdownOpen] = useState(false); 10 | return ( 11 |
12 |
13 |
14 |
15 |
16 | 23 | 29 | 30 | 31 |

TaskMaster

32 | 33 |
34 |
35 |
36 | 37 | {isLoading ? ( 38 |
39 | ) : session ? ( 40 |
41 |
42 | 72 | {dropdownOpen && ( 73 |
74 | setDropdownOpen(false)} 78 | > 79 | Dashboard 80 | 81 | setDropdownOpen(false)} 85 | > 86 | Your Profile 87 | 88 | {(session.user?.role === 'admin' || session.user?.role === 'super-admin') && ( 89 | setDropdownOpen(false)} 93 | > 94 | Manage Users 95 | 96 | )} 97 | 106 |
107 | )} 108 |
109 |
110 | ) : ( 111 |
112 | 116 | Sign in 117 | 118 | 122 | Sign up 123 | 124 |
125 | )} 126 |
127 |
128 |
129 |
130 | ); 131 | } 132 | -------------------------------------------------------------------------------- /src/app/auth/signin/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React, { useState, useEffect } from 'react'; 3 | import { signIn } from 'next-auth/react'; 4 | import { useRouter, useSearchParams } from 'next/navigation'; 5 | import Link from 'next/link'; 6 | import GoogleAuthButton from '@/components/GoogleAuthButton'; 7 | export default function SignIn() { 8 | const router = useRouter(); 9 | const searchParams = useSearchParams(); 10 | const [email, setEmail] = useState(''); 11 | const [password, setPassword] = useState(''); 12 | const [error, setError] = useState(''); 13 | const [success, setSuccess] = useState(''); 14 | const [isLoading, setIsLoading] = useState(false); 15 | useEffect(() => { 16 | if (searchParams.get('registered') === 'true') { 17 | setSuccess('Account created successfully! Please sign in with your new credentials.'); 18 | } 19 | }, [searchParams]); 20 | const handleSubmit = async (e: React.FormEvent) => { 21 | e.preventDefault(); 22 | setIsLoading(true); 23 | setError(''); 24 | try { 25 | const result = await signIn('credentials', { 26 | redirect: false, 27 | email, 28 | password, 29 | callbackUrl: '/', 30 | }); 31 | if (result?.error) { 32 | setError('Invalid email or password'); 33 | setIsLoading(false); 34 | return; 35 | } 36 | router.replace('/'); 37 | router.refresh(); 38 | } catch { 39 | setError('An error occurred. Please try again.'); 40 | setIsLoading(false); 41 | } 42 | }; 43 | return ( 44 |
45 |
46 |

47 | Sign in to your account 48 |

49 |
50 |
51 |
52 |
53 | {success && ( 54 |
55 |

{success}

56 |
57 | )} 58 | {error && ( 59 |
60 |

{error}

61 |
62 | )} 63 |
64 | 70 |
71 | setEmail(e.target.value)} 79 | className="appearance-none block w-full px-3 py-2 border border-gray-300 80 | dark:border-gray-700 dark:bg-gray-700 dark:text-white 81 | rounded-md shadow-sm placeholder-gray-400 82 | focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" 83 | /> 84 |
85 |
86 |
87 | 93 |
94 | setPassword(e.target.value)} 102 | className="appearance-none block w-full px-3 py-2 border border-gray-300 103 | dark:border-gray-700 dark:bg-gray-700 dark:text-white 104 | rounded-md shadow-sm placeholder-gray-400 105 | focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" 106 | /> 107 |
108 |
109 |
110 | 121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 | 129 | Or continue with 130 | 131 |
132 |
133 |
134 | 135 |
136 |
137 |

138 | Don't have an account?{' '} 139 | 143 | Sign up 144 | 145 |

146 |
147 |
148 |
149 |
150 | ); 151 | } 152 | -------------------------------------------------------------------------------- /src/app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React from 'react'; 3 | import { useSession } from 'next-auth/react'; 4 | import { useRouter } from 'next/navigation'; 5 | import { useEffect } from 'react'; 6 | import LoadingSpinner from '@/components/LoadingSpinner'; 7 | export default function DashboardLayout({ children }: { children: React.ReactNode }) { 8 | const { data: session, status } = useSession(); 9 | const router = useRouter(); 10 | const [sidebarOpen, setSidebarOpen] = React.useState(false); 11 | 12 | useEffect(() => { 13 | if (status === 'unauthenticated') { 14 | router.push('/auth/signin'); 15 | } 16 | }, [status, router]); 17 | 18 | if (status === 'loading') { 19 | return ( 20 |
21 | 22 |
23 | ); 24 | } 25 | 26 | return ( 27 |
28 | {/* Mobile menu button */} 29 |
30 | 44 |
45 | 46 | {/* Sidebar */} 47 | 163 |
164 | {children} 165 |
166 |
167 | ); 168 | } 169 | -------------------------------------------------------------------------------- /src/app/api/admin/users/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from 'next/server'; 2 | import { connectToDatabase } from '@/lib/mongodb'; 3 | import UserModel from '@/models/User'; 4 | import { auth } from '@/app/api/auth/[...nextauth]/route'; 5 | import { UserRole, AdminPermissions } from '@/models/user.interface'; 6 | interface Params { 7 | params: { 8 | id: string; 9 | }; 10 | } 11 | export async function PUT(req: NextRequest, { params }: Params) { 12 | try { 13 | const session = await auth(); 14 | if (!session || (session.user.role !== 'admin' && session.user.role !== 'super-admin')) { 15 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); 16 | } 17 | const userId = params.id; 18 | if (!userId) { 19 | return NextResponse.json({ error: 'User ID is required' }, { status: 400 }); 20 | } 21 | await connectToDatabase(); 22 | const currentAdmin = 23 | session.user.role === 'admin' ? await UserModel.findById(session.user.id) : null; 24 | const adminPermissions = currentAdmin?.adminPermissions as AdminPermissions | undefined; 25 | const { 26 | role, 27 | name, 28 | email, 29 | adminPermissions: newAdminPermissions, 30 | } = (await req.json()) as { 31 | role?: UserRole; 32 | name?: string; 33 | email?: string; 34 | adminPermissions?: AdminPermissions; 35 | }; 36 | if (role && !['user', 'admin', 'super-admin'].includes(role)) { 37 | return NextResponse.json({ error: 'Valid role is required' }, { status: 400 }); 38 | } 39 | const userToUpdate = await UserModel.findById(userId); 40 | if (!userToUpdate) { 41 | return NextResponse.json({ error: 'User not found' }, { status: 404 }); 42 | } 43 | const updateData: { 44 | role?: UserRole; 45 | name?: string; 46 | email?: string; 47 | adminPermissions?: AdminPermissions | null; 48 | $unset?: { adminPermissions?: string }; 49 | } = {}; 50 | if (role) updateData.role = role; 51 | if (name) updateData.name = name; 52 | if (email) updateData.email = email; 53 | if (role === 'admin') { 54 | updateData.adminPermissions = newAdminPermissions || { 55 | canUpdateUserInfo: true, 56 | canDeleteUsers: false, 57 | canPromoteToAdmin: false, 58 | canDemoteAdmins: false, 59 | }; 60 | } else if (role === 'user' || role === 'super-admin') { 61 | updateData.$unset = { adminPermissions: '' }; 62 | } 63 | if (!role && userToUpdate.role === 'admin' && newAdminPermissions) { 64 | updateData.adminPermissions = newAdminPermissions; 65 | } 66 | if (session.user.role === 'super-admin') { 67 | } else if (session.user.role === 'admin') { 68 | if ((name || email) && (!adminPermissions || !adminPermissions.canUpdateUserInfo)) { 69 | return NextResponse.json( 70 | { 71 | error: 'You do not have permission to update user information', 72 | }, 73 | { status: 403 } 74 | ); 75 | } 76 | if (role) { 77 | if (role === 'admin' && userToUpdate.role === 'user') { 78 | } else if (role === 'user' && userToUpdate.role === 'admin') { 79 | if (!adminPermissions?.canDemoteAdmins) { 80 | return NextResponse.json( 81 | { 82 | error: 'You do not have permission to demote admins', 83 | }, 84 | { status: 403 } 85 | ); 86 | } 87 | } else if (role === 'super-admin') { 88 | return NextResponse.json( 89 | { 90 | error: 'Only super-admins can promote users to super-admin', 91 | }, 92 | { status: 403 } 93 | ); 94 | } 95 | } 96 | if (newAdminPermissions) { 97 | if (userToUpdate.role !== 'admin' && !adminPermissions?.canPromoteToAdmin) { 98 | return NextResponse.json( 99 | { 100 | error: 'You do not have permission to update admin permissions', 101 | }, 102 | { status: 403 } 103 | ); 104 | } 105 | } 106 | if (userToUpdate.role !== 'user') { 107 | return NextResponse.json( 108 | { 109 | error: 'Regular admins can only update regular users', 110 | }, 111 | { status: 403 } 112 | ); 113 | } 114 | } 115 | if (updateData.$unset) { 116 | await UserModel.updateOne({ _id: userId }, { $unset: updateData.$unset }); 117 | delete updateData.$unset; 118 | } 119 | const user = await UserModel.findByIdAndUpdate(userId, updateData, { 120 | new: true, 121 | select: '-password', 122 | }); 123 | if (!user) { 124 | return NextResponse.json({ error: 'User not found' }, { status: 404 }); 125 | } 126 | return NextResponse.json(user); 127 | } catch (_error) { 128 | const errorMessage = error instanceof Error ? error.message : 'Internal server error'; 129 | return NextResponse.json({ error: errorMessage }, { status: 500 }); 130 | } 131 | } 132 | export async function DELETE(req: NextRequest, { params }: Params) { 133 | try { 134 | const session = await auth(); 135 | if (!session || (session.user.role !== 'admin' && session.user.role !== 'super-admin')) { 136 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); 137 | } 138 | const userId = params.id; 139 | if (!userId) { 140 | return NextResponse.json({ error: 'User ID is required' }, { status: 400 }); 141 | } 142 | await connectToDatabase(); 143 | const currentAdmin = 144 | session.user.role === 'admin' ? await UserModel.findById(session.user.id) : null; 145 | const adminPermissions = currentAdmin?.adminPermissions as AdminPermissions | undefined; 146 | const userToDelete = await UserModel.findById(userId); 147 | if (!userToDelete) { 148 | return NextResponse.json({ error: 'User not found' }, { status: 404 }); 149 | } 150 | if (userToDelete.role === 'super-admin' && session.user.role !== 'super-admin') { 151 | return NextResponse.json( 152 | { 153 | error: 'You do not have permission to delete a super-admin user', 154 | }, 155 | { status: 403 } 156 | ); 157 | } 158 | if (userId === session.user.id) { 159 | return NextResponse.json( 160 | { 161 | error: 'You cannot delete your own account', 162 | }, 163 | { status: 400 } 164 | ); 165 | } 166 | if (session.user.role === 'admin') { 167 | if (!adminPermissions || !adminPermissions.canDeleteUsers) { 168 | return NextResponse.json( 169 | { 170 | error: 'You do not have permission to delete users', 171 | }, 172 | { status: 403 } 173 | ); 174 | } 175 | if (userToDelete.role !== 'user') { 176 | return NextResponse.json( 177 | { 178 | error: `As an Admin, you can only delete regular users, not ${userToDelete.role}s`, 179 | }, 180 | { status: 403 } 181 | ); 182 | } 183 | } 184 | await UserModel.findByIdAndDelete(userId); 185 | return NextResponse.json({ 186 | message: `User deleted successfully`, 187 | }); 188 | } catch (_error) { 189 | const errorMessage = error instanceof Error ? error.message : 'Internal server error'; 190 | return NextResponse.json({ error: errorMessage }, { status: 500 }); 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/app/auth/signup/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import React, { useState } from 'react'; 3 | import { useRouter } from 'next/navigation'; 4 | import { signIn } from 'next-auth/react'; 5 | import Link from 'next/link'; 6 | import GoogleAuthButton from '@/components/GoogleAuthButton'; 7 | export default function SignupPage() { 8 | const router = useRouter(); 9 | const [name, setName] = useState(''); 10 | const [email, setEmail] = useState(''); 11 | const [password, setPassword] = useState(''); 12 | const [error, setError] = useState(''); 13 | const [isLoading, setIsLoading] = useState(false); 14 | const handleSubmit = async (e: React.FormEvent) => { 15 | e.preventDefault(); 16 | setIsLoading(true); 17 | setError(''); 18 | try { 19 | if (password.length < 8) { 20 | throw new Error('Password must be at least 8 characters long'); 21 | } 22 | const response = await fetch('/api/auth/register', { 23 | method: 'POST', 24 | headers: { 25 | 'Content-Type': 'application/json', 26 | }, 27 | body: JSON.stringify({ name, email, password }), 28 | }); 29 | const data = await response.json(); 30 | if (!response.ok) { 31 | throw new Error(data.error || 'Registration failed'); 32 | } 33 | const signInResult = await signIn('credentials', { 34 | redirect: false, 35 | email, 36 | password, 37 | }); 38 | if (signInResult?.error) { 39 | router.replace('/auth/signin?registered=true'); 40 | } else { 41 | router.replace('/'); 42 | router.refresh(); 43 | } 44 | } catch (err) { 45 | setError(err instanceof Error ? err.message : 'Registration failed'); 46 | } finally { 47 | setIsLoading(false); 48 | } 49 | }; 50 | return ( 51 |
52 |
53 |

54 | Create a new account 55 |

56 |
57 |
58 |
59 |
60 | {error && ( 61 |
62 |

{error}

63 |
64 | )} 65 |
66 | 72 |
73 | setName(e.target.value)} 81 | className="appearance-none block w-full px-3 py-2 border border-gray-300 82 | dark:border-gray-700 dark:bg-gray-700 dark:text-white 83 | rounded-md shadow-sm placeholder-gray-400 84 | focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" 85 | /> 86 |
87 |
88 |
89 | 95 |
96 | setEmail(e.target.value)} 104 | className="appearance-none block w-full px-3 py-2 border border-gray-300 105 | dark:border-gray-700 dark:bg-gray-700 dark:text-white 106 | rounded-md shadow-sm placeholder-gray-400 107 | focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" 108 | /> 109 |
110 |
111 |
112 | 118 |
119 | setPassword(e.target.value)} 127 | className="appearance-none block w-full px-3 py-2 border border-gray-300 128 | dark:border-gray-700 dark:bg-gray-700 dark:text-white 129 | rounded-md shadow-sm placeholder-gray-400 130 | focus:outline-none focus:ring-indigo-500 focus:border-indigo-500" 131 | /> 132 |
133 |
134 |
135 | 146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 | 154 | Or sign up with 155 | 156 |
157 |
158 |
159 | 160 |
161 |
162 |

163 | Already have an account?{' '} 164 | 168 | Sign in 169 | 170 |

171 |
172 |
173 |
174 |
175 | ); 176 | } 177 | -------------------------------------------------------------------------------- /src/app/api/admin/users/route.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | /* eslint-disable @typescript-eslint/no-unused-vars */ 3 | import { NextRequest, NextResponse } from 'next/server'; 4 | import { connectToDatabase } from '@/lib/mongodb'; 5 | import UserModel from '@/models/User'; 6 | import { auth } from '@/app/api/auth/[...nextauth]/route'; 7 | import { AdminPermissions, UserRole } from '@/models/user.interface'; 8 | export async function GET() { 9 | try { 10 | const session = await auth(); 11 | if (!session || (session.user.role !== 'admin' && session.user.role !== 'super-admin')) { 12 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); 13 | } 14 | await connectToDatabase(); 15 | const users = await UserModel.find( 16 | {}, 17 | { 18 | password: 0, 19 | } 20 | ).sort({ createdAt: -1 }); 21 | return NextResponse.json(users); 22 | 23 | } catch (_error) { 24 | const errorMessage = error instanceof Error ? error.message : 'Internal server error'; 25 | return NextResponse.json({ error: errorMessage }, { status: 500 }); 26 | } 27 | } 28 | export async function POST(req: NextRequest) { 29 | try { 30 | const session = await auth(); 31 | if (!session || (session.user.role !== 'admin' && session.user.role !== 'super-admin')) { 32 | return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }); 33 | } 34 | const body = await req.json(); 35 | if (body.userIds) { 36 | return await handleBulkDelete(req, session, body.userIds); 37 | } else if (body.user) { 38 | return await handleCreateUser(req, session, body.user); 39 | } 40 | return NextResponse.json({ error: 'Invalid request' }, { status: 400 }); 41 | } catch (_error) { 42 | const errorMessage = error instanceof Error ? error.message : 'Internal server error'; 43 | return NextResponse.json({ error: errorMessage }, { status: 500 }); 44 | } 45 | } 46 | async function handleCreateUser( 47 | req: NextRequest, 48 | session: { user: { id: string; role: UserRole; adminPermissions?: AdminPermissions } }, 49 | userData: { 50 | name: string; 51 | email: string; 52 | password: string; 53 | role?: UserRole; 54 | adminPermissions?: AdminPermissions; 55 | } 56 | ) { 57 | try { 58 | const { name, email, password, role = 'user', adminPermissions } = userData; 59 | if (!name || !email || !password) { 60 | return NextResponse.json({ error: 'Missing required fields' }, { status: 400 }); 61 | } 62 | await connectToDatabase(); 63 | const existingUser = await UserModel.findOne({ email }); 64 | if (existingUser) { 65 | const updatedUser = await UserModel.findByIdAndUpdate( 66 | existingUser._id, 67 | { 68 | $set: { 69 | name, 70 | role, 71 | ...(password 72 | ? { password: await (await import('@/lib/auth')).hashPassword(password) } 73 | : {}), 74 | ...(role === 'admin' 75 | ? { 76 | adminPermissions: adminPermissions || { 77 | canUpdateUserInfo: true, 78 | canDeleteUsers: false, 79 | canPromoteToAdmin: true, 80 | canDemoteAdmins: false, 81 | }, 82 | } 83 | : {}), 84 | }, 85 | }, 86 | { new: true } 87 | ).select('-password'); 88 | return NextResponse.json( 89 | { message: 'User updated successfully', user: updatedUser }, 90 | { status: 200 } 91 | ); 92 | } 93 | if (session.user.role === 'admin') { 94 | if (role === 'admin') { 95 | } else if (role === 'super-admin') { 96 | return NextResponse.json( 97 | { error: 'Only super-admins can create super-admin users' }, 98 | { status: 403 } 99 | ); 100 | } 101 | } 102 | const { hashPassword } = await import('@/lib/auth'); 103 | const hashedPassword = await hashPassword(password); 104 | const userObject: { 105 | name: string; 106 | email: string; 107 | password: string; 108 | role: string; 109 | adminPermissions?: typeof adminPermissions; 110 | } = { 111 | name, 112 | email, 113 | password: hashedPassword, 114 | role, 115 | }; 116 | if (role === 'admin') { 117 | userObject.adminPermissions = adminPermissions || { 118 | canUpdateUserInfo: true, 119 | canDeleteUsers: false, 120 | canPromoteToAdmin: true, 121 | canDemoteAdmins: false, 122 | }; 123 | } 124 | const user = await UserModel.create(userObject); 125 | const responseUser = user.toObject(); 126 | delete responseUser.password; 127 | return NextResponse.json( 128 | { message: 'User created successfully', user: responseUser }, 129 | { status: 201 } 130 | ); 131 | } catch (_error) { 132 | const errorMessage = error instanceof Error ? error.message : 'Internal server error'; 133 | return NextResponse.json({ error: errorMessage }, { status: 500 }); 134 | } 135 | } 136 | async function handleBulkDelete( 137 | req: NextRequest, 138 | session: { user: { id: string; role: UserRole; adminPermissions?: AdminPermissions } }, 139 | userIds: string[] 140 | ) { 141 | try { 142 | if (!userIds || !Array.isArray(userIds) || userIds.length === 0) { 143 | return NextResponse.json({ error: 'User IDs are required' }, { status: 400 }); 144 | } 145 | if (!userIds || !Array.isArray(userIds) || userIds.length === 0) { 146 | return NextResponse.json({ error: 'User IDs are required' }, { status: 400 }); 147 | } 148 | await connectToDatabase(); 149 | const usersToDelete = await UserModel.find({ _id: { $in: userIds } }); 150 | if (session.user.role === 'admin') { 151 | const currentAdmin = await UserModel.findById(session.user.id); 152 | if (!currentAdmin?.adminPermissions?.canDeleteUsers) { 153 | return NextResponse.json( 154 | { 155 | error: 'You do not have permission to delete users', 156 | }, 157 | { status: 403 } 158 | ); 159 | } 160 | const hasNonRegularUsers = usersToDelete.some( 161 | user => user.role === 'admin' || user.role === 'super-admin' 162 | ); 163 | if (hasNonRegularUsers) { 164 | return NextResponse.json( 165 | { 166 | error: 'As an Admin, you can only delete regular users', 167 | }, 168 | { status: 403 } 169 | ); 170 | } 171 | } 172 | const hasSuperAdmin = usersToDelete.some(user => user.role === 'super-admin'); 173 | if (hasSuperAdmin && session.user.role !== 'super-admin') { 174 | return NextResponse.json( 175 | { 176 | error: 'You do not have permission to delete super-admin users', 177 | }, 178 | { status: 403 } 179 | ); 180 | } 181 | const isDeletingSelf = usersToDelete.some(user => user._id.toString() === session.user.id); 182 | if (isDeletingSelf) { 183 | return NextResponse.json( 184 | { 185 | error: 'You cannot delete your own account', 186 | }, 187 | { status: 400 } 188 | ); 189 | } 190 | const result = await UserModel.deleteMany({ _id: { $in: userIds } }); 191 | return NextResponse.json({ 192 | message: `Successfully deleted ${result.deletedCount} users`, 193 | deletedCount: result.deletedCount, 194 | }); 195 | } catch (_error) { 196 | const errorMessage = error instanceof Error ? error.message : 'Internal server error'; 197 | return NextResponse.json({ error: errorMessage }, { status: 500 }); 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { useSession } from 'next-auth/react'; 3 | import { UserRole } from '@/models/user.interface'; 4 | import { useGetStatsQuery } from '@/features/todos/api'; 5 | import LoadingSpinner from '@/components/LoadingSpinner'; 6 | export default function DashboardPage() { 7 | const { data: session } = useSession(); 8 | const userRole = session?.user?.role as UserRole; 9 | const { data: stats, isLoading, error } = useGetStatsQuery(); 10 | return ( 11 |
12 |

Dashboard

13 |
14 |

Welcome, {session?.user?.name}!

15 |
16 | Email: 17 | {session?.user?.email} 18 |
19 |
20 | Role: 21 | 30 | {userRole === 'super-admin' 31 | ? 'Super Administrator' 32 | : userRole === 'admin' 33 | ? 'Administrator' 34 | : 'User'} 35 | 36 |
37 | {(userRole === 'admin' || userRole === 'super-admin') && ( 38 |
39 |

Your Admin Permissions:

40 |
    41 |
  • ✓ View all users
  • 42 |
  • ✓ Delete regular users
  • 43 |
  • ✓ Update user information
  • 44 | {userRole === 'super-admin' ? ( 45 | <> 46 |
  • 47 | ✓ Promote users to Admin/Super Admin 48 |
  • 49 |
  • ✓ Delete Admin users
  • 50 | 51 | ) : ( 52 | <> 53 |
  • ✗ Cannot promote users
  • 54 |
  • 55 | ✗ Cannot delete Admin/Super Admin users 56 |
  • 57 | 58 | )} 59 |
60 |
61 | )} 62 |
63 |
64 |
65 |

Quick Summary

66 | {isLoading ? ( 67 |
68 | 69 |
70 | ) : error ? ( 71 |
Failed to load stats
72 | ) : stats ? ( 73 |
74 |
75 | Active Tasks 76 | {stats.userStats.active} 77 |
78 |
79 | Completed Tasks 80 | {stats.userStats.completed} 81 |
82 |
83 | Pending Tasks 84 | {stats.userStats.pending} 85 |
86 |
87 | Total Tasks 88 | {stats.userStats.total} 89 |
90 |
91 | ) : ( 92 |
93 |
94 | Active Tasks 95 | - 96 |
97 |
98 | Completed Tasks 99 | - 100 |
101 |
102 | Pending Tasks 103 | - 104 |
105 |
106 | Total Tasks 107 | - 108 |
109 |
110 | )} 111 |
112 | {(userRole === 'admin' || userRole === 'super-admin') && ( 113 |
114 |

Admin Overview

115 | {isLoading ? ( 116 |
117 | 118 |
119 | ) : error ? ( 120 |
Failed to load stats
121 | ) : stats?.adminStats ? ( 122 |
123 |
124 | Total Users 125 | {stats.adminStats.totalUsers} 126 |
127 |
128 | Total Tasks 129 | {stats.adminStats.totalTasks} 130 |
131 |
132 | System Status 133 | 134 | {stats.adminStats.systemStatus} 135 | 136 |
137 | {userRole === 'super-admin' && ( 138 | 146 | )} 147 |
148 | ) : ( 149 |
150 |
151 | Total Users 152 | - 153 |
154 |
155 | Total Tasks 156 | - 157 |
158 |
159 | System Status 160 | Active 161 |
162 | {userRole === 'super-admin' && ( 163 | 171 | )} 172 |
173 | )} 174 |
175 | )} 176 |
177 |
178 | ); 179 | } 180 | -------------------------------------------------------------------------------- /src/app/dashboard/profile/page.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | /* eslint-disable no-undef */ 3 | /* eslint-disable @typescript-eslint/no-unused-vars */ 4 | 'use client'; 5 | import { useState, useEffect, FormEvent } from 'react'; 6 | import { useSession } from 'next-auth/react'; 7 | import { useRouter } from 'next/navigation'; 8 | import Image from 'next/image'; 9 | import LoadingSpinner from '@/components/LoadingSpinner'; 10 | import error from 'next/error'; 11 | interface UserProfile { 12 | _id: string; 13 | name: string; 14 | email: string; 15 | role: string; 16 | image?: string; 17 | adminPermissions?: { 18 | canUpdateUserInfo: boolean; 19 | canDeleteUsers: boolean; 20 | }; 21 | } 22 | export default function ProfilePage() { 23 | const { status, update } = useSession(); 24 | const router = useRouter(); 25 | const [name, setName] = useState(''); 26 | const [email, setEmail] = useState(''); 27 | const [currentPassword, setCurrentPassword] = useState(''); 28 | const [newPassword, setNewPassword] = useState(''); 29 | const [confirmPassword, setConfirmPassword] = useState(''); 30 | const [loading, setLoading] = useState(true); 31 | const [updating, setUpdating] = useState(false); 32 | const [errorMsg, setErrorMsg] = useState(null); 33 | const [successMsg, setSuccessMsg] = useState(null); 34 | const [userProfile, setUserProfile] = useState(null); 35 | useEffect(() => { 36 | if (status === 'unauthenticated') { 37 | router.push('/auth/signin'); 38 | return; 39 | } 40 | if (status === 'authenticated') { 41 | fetchUserProfile(); 42 | } 43 | }, [status, router]); 44 | const fetchUserProfile = async () => { 45 | try { 46 | setLoading(true); 47 | const response = await fetch('/api/users/profile'); 48 | if (!response.ok) { 49 | throw new Error('Failed to fetch profile'); 50 | } 51 | const userData = await response.json(); 52 | setUserProfile(userData); 53 | setName(userData.name || ''); 54 | setEmail(userData.email || ''); 55 | setLoading(false); 56 | } catch { 57 | setErrorMsg('Failed to load profile data'); 58 | setLoading(false); 59 | } 60 | }; 61 | const handleSubmit = async (e: FormEvent) => { 62 | e.preventDefault(); 63 | setErrorMsg(null); 64 | setSuccessMsg(null); 65 | if (newPassword && newPassword !== confirmPassword) { 66 | setErrorMsg('New passwords do not match'); 67 | return; 68 | } 69 | if (newPassword && newPassword.length < 8) { 70 | setErrorMsg('Password must be at least 8 characters long'); 71 | return; 72 | } 73 | const updateData: { 74 | name?: string; 75 | email?: string; 76 | currentPassword?: string; 77 | newPassword?: string; 78 | } = {}; 79 | if (name && name !== userProfile?.name) updateData.name = name; 80 | if (email && email !== userProfile?.email) updateData.email = email; 81 | if (newPassword) { 82 | updateData.currentPassword = currentPassword; 83 | updateData.newPassword = newPassword; 84 | } 85 | if (Object.keys(updateData).length === 0) { 86 | setSuccessMsg('No changes to save'); 87 | return; 88 | } 89 | try { 90 | setUpdating(true); 91 | const response = await fetch('/api/users/profile', { 92 | method: 'PUT', 93 | headers: { 94 | 'Content-Type': 'application/json', 95 | }, 96 | body: JSON.stringify(updateData), 97 | }); 98 | const data = await response.json(); 99 | if (!response.ok) { 100 | throw new Error(data.error || 'Failed to update profile'); 101 | } 102 | setSuccessMsg('Profile updated successfully'); 103 | if (updateData.name || updateData.email) { 104 | await update(); 105 | } 106 | setCurrentPassword(''); 107 | setNewPassword(''); 108 | setConfirmPassword(''); 109 | } catch (_error) { 110 | if (error instanceof Error) { 111 | setErrorMsg(error.message); 112 | } else { 113 | setErrorMsg('An unexpected error occurred'); 114 | } 115 | } finally { 116 | setUpdating(false); 117 | } 118 | }; 119 | if (status === 'loading' || loading) { 120 | return ( 121 |
122 | 123 |
124 | ); 125 | } 126 | return ( 127 |
128 |

Your Profile

129 | {errorMsg && ( 130 |
131 | {errorMsg} 132 |
133 | )} 134 | {successMsg && ( 135 |
136 | {successMsg} 137 |
138 | )} 139 |
140 |
141 | {userProfile?.image ? ( 142 | {userProfile.name} 149 | ) : ( 150 |
151 | 152 | {userProfile?.name?.charAt(0)?.toUpperCase()} 153 | 154 |
155 | )} 156 |
157 |

{userProfile?.name}

158 |

{userProfile?.email}

159 | 160 | {userProfile?.role} 161 | 162 |
163 |
164 |
165 |
166 |
167 | 170 | setName(e.target.value)} 175 | className="w-full rounded-md border border-input bg-background px-3 py-2" 176 | placeholder="Your name" 177 | /> 178 |
179 |
180 | 183 | setEmail(e.target.value)} 188 | className="w-full rounded-md border border-input bg-background px-3 py-2" 189 | placeholder="Your email" 190 | /> 191 |
192 |
193 |

Change Password

194 |

195 | Leave blank if you don't want to change your password. 196 |

197 |
198 |
199 | 202 | setCurrentPassword(e.target.value)} 207 | className="w-full rounded-md border border-input bg-background px-3 py-2" 208 | placeholder="Your current password" 209 | /> 210 |
211 |
212 | 215 | setNewPassword(e.target.value)} 220 | className="w-full rounded-md border border-input bg-background px-3 py-2" 221 | placeholder="New password" 222 | /> 223 |
224 |
225 | 228 | setConfirmPassword(e.target.value)} 233 | className="w-full rounded-md border border-input bg-background px-3 py-2" 234 | placeholder="Confirm new password" 235 | /> 236 |
237 |
238 |
239 |
240 | 247 |
248 |
249 |
250 |
251 |
252 | ); 253 | } 254 | -------------------------------------------------------------------------------- /src/features/todos/ToDoList.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-vars */ 2 | /* eslint-disable @typescript-eslint/no-unused-vars */ 3 | 'use client'; 4 | import { useState } from 'react'; 5 | import { useSession } from 'next-auth/react'; 6 | import { 7 | useGetTodosQuery, 8 | useAddTodoMutation, 9 | useDeleteTodoMutation, 10 | useUpdateTodoMutation, 11 | } from './api'; 12 | import { useTodoInputStore } from './store'; 13 | import { ITodo } from '@/types/todo'; 14 | import { toast } from 'sonner'; 15 | export default function ToDoList() { 16 | const { status: authStatus } = useSession(); 17 | const { data, isLoading } = useGetTodosQuery(undefined, { 18 | skip: authStatus !== 'authenticated', 19 | }); 20 | const input = useTodoInputStore(s => s.input); 21 | const setInput = useTodoInputStore(s => s.setInput); 22 | const reset = useTodoInputStore(s => s.reset); 23 | const [addTodo] = useAddTodoMutation(); 24 | const [deleteTodo] = useDeleteTodoMutation(); 25 | const [updateTodo] = useUpdateTodoMutation(); 26 | const [editMode, setEditMode] = useState(null); 27 | const [editInput, setEditInput] = useState(''); 28 | const handleAddTodo = async () => { 29 | if (!input.trim() || authStatus !== 'authenticated') return; 30 | try { 31 | toast.promise( 32 | addTodo({ 33 | title: input, 34 | completed: false, 35 | }).unwrap(), 36 | { 37 | loading: 'Adding task...', 38 | success: 'Task added successfully!', 39 | error: 'Failed to add task', 40 | } 41 | ); 42 | reset(); 43 | } catch (_error) { 44 | toast.error('Failed to add task'); 45 | } 46 | }; 47 | const handleToggleComplete = async (todo: ITodo) => { 48 | if (authStatus !== 'authenticated') return; 49 | try { 50 | toast.promise( 51 | updateTodo({ 52 | ...todo, 53 | completed: !todo.completed, 54 | }).unwrap(), 55 | { 56 | loading: 'Updating task...', 57 | success: `Task marked as ${!todo.completed ? 'completed' : 'active'}`, 58 | error: 'Failed to update task', 59 | } 60 | ); 61 | } catch (_error) { 62 | toast.error('Failed to update task'); 63 | } 64 | }; 65 | const handleDelete = async (id: string | number) => { 66 | if (authStatus !== 'authenticated') return; 67 | try { 68 | toast.promise(deleteTodo(id).unwrap(), { 69 | loading: 'Deleting task...', 70 | success: 'Task deleted successfully!', 71 | error: 'Failed to delete task', 72 | }); 73 | } catch (_error) { 74 | toast.error('Failed to delete task'); 75 | } 76 | }; 77 | const startEdit = (todo: ITodo) => { 78 | setEditMode(todo.id); 79 | setEditInput(todo.title); 80 | }; 81 | const cancelEdit = () => { 82 | setEditMode(null); 83 | setEditInput(''); 84 | }; 85 | const saveEdit = async (todo: ITodo) => { 86 | if (!editInput.trim()) return; 87 | try { 88 | toast.promise( 89 | updateTodo({ 90 | ...todo, 91 | title: editInput, 92 | }).unwrap(), 93 | { 94 | loading: 'Saving changes...', 95 | success: 'Task updated successfully!', 96 | error: 'Failed to update task', 97 | } 98 | ); 99 | setEditMode(null); 100 | setEditInput(''); 101 | } catch (_error) { 102 | toast.error('Failed to update task'); 103 | } 104 | }; 105 | if (isLoading) 106 | return ( 107 |
108 |
109 |
110 | ); 111 | return ( 112 |
113 |
114 |

115 | 122 | 128 | 129 | Task Manager 130 |

131 |

Organize your day efficiently

132 |
133 |
134 |
135 | setInput(e.target.value)} 139 | className="flex-grow border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-l-lg px-4 py-3 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition duration-150" 140 | placeholder="What do you need to accomplish today?" 141 | onKeyPress={e => e.key === 'Enter' && handleAddTodo()} 142 | /> 143 | 161 |
162 | {data?.length === 0 ? ( 163 |
164 | 171 | 177 | 178 |

179 | No tasks yet. Add one to get started! 180 |

181 |
182 | ) : ( 183 |
    184 | {data?.map(todo => ( 185 |
  • 189 | {editMode === todo.id ? ( 190 |
    191 | setEditInput(e.target.value)} 195 | className="flex-grow border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white rounded-lg px-3 py-2 mr-2 focus:outline-none focus:ring-2 focus:ring-indigo-500" 196 | autoFocus 197 | onKeyPress={e => e.key === 'Enter' && saveEdit(todo)} 198 | /> 199 | 217 | 235 |
    236 | ) : ( 237 |
    238 |
    239 |
    240 | handleToggleComplete(todo)} 244 | className="h-5 w-5 rounded text-indigo-600 focus:ring-indigo-500 mr-3 appearance-none border border-gray-300 dark:border-gray-600 checked:bg-indigo-600 checked:border-transparent" 245 | /> 246 | {todo.completed && ( 247 | 253 | 258 | 259 | )} 260 |
    261 | 268 | {todo.title} 269 | 270 |
    271 |
    272 | 286 | 304 |
    305 |
    306 | )} 307 |
  • 308 | ))} 309 |
310 | )} 311 |
312 |
313 | ); 314 | } 315 | -------------------------------------------------------------------------------- /src/app/dashboard/admin/tasks/page.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 'use client'; 3 | 4 | import { useState, useEffect } from 'react'; 5 | import { useSession } from 'next-auth/react'; 6 | import { useRouter } from 'next/navigation'; 7 | import LoadingSpinner from '@/components/LoadingSpinner'; 8 | import { RolePermissions } from '@/lib/permissions'; 9 | import { ITodo } from '@/types/todo'; 10 | 11 | interface AdminTodo extends ITodo { 12 | createdAt: string; 13 | updatedAt: string; 14 | id: string; 15 | user: { 16 | id: string; 17 | name: string; 18 | email: string; 19 | } | null; 20 | } 21 | 22 | export default function AdminTasksPage() { 23 | const { data: session, status } = useSession(); 24 | const router = useRouter(); 25 | const [todos, setTodos] = useState([]); 26 | const [loading, setLoading] = useState(true); 27 | const [error, setError] = useState(null); 28 | const [searchTerm, setSearchTerm] = useState(''); 29 | const [currentPage, setCurrentPage] = useState(1); 30 | const [totalPages, setTotalPages] = useState(1); 31 | const [isEditing, setIsEditing] = useState(null); 32 | const [editTitle, setEditTitle] = useState(''); 33 | const [deleteLoading, setDeleteLoading] = useState(null); 34 | const [updateLoading, setUpdateLoading] = useState(null); 35 | 36 | useEffect(() => { 37 | if (status === 'loading') return; 38 | 39 | if (!session) { 40 | router.push('/auth/signin'); 41 | return; 42 | } 43 | 44 | const userRole = session.user?.role; 45 | if (!RolePermissions.canManageAllTodos(userRole)) { 46 | router.push('/dashboard'); 47 | return; 48 | } 49 | 50 | fetchTodos(); 51 | }, [session, status, router, currentPage, searchTerm]); 52 | 53 | async function fetchTodos() { 54 | setLoading(true); 55 | try { 56 | const searchParams = new URLSearchParams(); 57 | searchParams.set('page', currentPage.toString()); 58 | searchParams.set('limit', '20'); 59 | if (searchTerm) { 60 | searchParams.set('search', searchTerm); 61 | } 62 | 63 | const response = await fetch(`/api/admin/todos?${searchParams.toString()}`); 64 | if (!response.ok) { 65 | throw new Error(`Error ${response.status}: ${response.statusText}`); 66 | } 67 | 68 | const data = await response.json(); 69 | setTodos(data.todos); 70 | setTotalPages(data.pagination.pages); 71 | } catch (err: any) { 72 | setError(err.message || 'Failed to fetch todos'); 73 | } finally { 74 | setLoading(false); 75 | } 76 | } 77 | 78 | async function handleDelete(id: string) { 79 | if (!confirm('Are you sure you want to delete this task?')) { 80 | return; 81 | } 82 | 83 | setDeleteLoading(id); 84 | try { 85 | const response = await fetch(`/api/admin/todos/${id}`, { 86 | method: 'DELETE', 87 | }); 88 | 89 | if (!response.ok) { 90 | throw new Error(`Error ${response.status}: ${response.statusText}`); 91 | } 92 | 93 | // Remove the deleted todo from the list 94 | setTodos(todos.filter(todo => todo.id !== id)); 95 | } catch (err: any) { 96 | setError(err.message || 'Failed to delete todo'); 97 | } finally { 98 | setDeleteLoading(null); 99 | } 100 | } 101 | 102 | async function handleUpdate(id: string, completed?: boolean) { 103 | setUpdateLoading(id); 104 | try { 105 | const payload: { title?: string; completed?: boolean } = {}; 106 | 107 | if (isEditing === id) { 108 | payload.title = editTitle; 109 | } 110 | 111 | if (completed !== undefined) { 112 | payload.completed = completed; 113 | } 114 | 115 | const response = await fetch(`/api/admin/todos/${id}`, { 116 | method: 'PATCH', 117 | headers: { 118 | 'Content-Type': 'application/json', 119 | }, 120 | body: JSON.stringify(payload), 121 | }); 122 | 123 | if (!response.ok) { 124 | throw new Error(`Error ${response.status}: ${response.statusText}`); 125 | } 126 | 127 | const updatedTodo = await response.json(); 128 | 129 | // Update the todo in the list 130 | setTodos(todos.map(todo => (todo.id === id ? { ...todo, ...updatedTodo } : todo))); 131 | 132 | setIsEditing(null); 133 | } catch (err: any) { 134 | setError(err.message || 'Failed to update todo'); 135 | } finally { 136 | setUpdateLoading(null); 137 | } 138 | } 139 | 140 | function handleEdit(todo: AdminTodo) { 141 | setIsEditing(todo.id); 142 | setEditTitle(todo.title); 143 | } 144 | 145 | function cancelEdit() { 146 | setIsEditing(null); 147 | } 148 | 149 | if (status === 'loading' || (loading && !todos.length)) { 150 | return ( 151 |
152 | 153 |
154 | ); 155 | } 156 | 157 | return ( 158 |
159 |

Manage All Tasks

160 | 161 | {error && ( 162 |
163 | {error} 164 |
165 | )} 166 | 167 |
168 |
169 | setSearchTerm(e.target.value)} 174 | className="px-4 py-2 border rounded-md w-full max-w-md bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600" 175 | /> 176 | 182 |
183 |
184 | 185 |
186 |
187 | 188 | 189 | 190 | 193 | 196 | 199 | 202 | 205 | 206 | 207 | 208 | {loading ? ( 209 | 210 | 213 | 214 | ) : todos.length === 0 ? ( 215 | 216 | 222 | 223 | ) : ( 224 | todos.map(todo => ( 225 | 226 | 243 | 257 | 267 | 270 | 305 | 306 | )) 307 | )} 308 | 309 |
191 | Title 192 | 194 | Status 195 | 197 | User 198 | 200 | Created 201 | 203 | Actions 204 |
211 | 212 |
220 | No tasks found 221 |
227 | {isEditing === todo.id ? ( 228 | setEditTitle(e.target.value)} 232 | className="px-2 py-1 border rounded-md w-full bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600" 233 | autoFocus 234 | /> 235 | ) : ( 236 | 239 | {todo.title} 240 | 241 | )} 242 | 244 |
245 | handleUpdate(todo.id, !todo.completed)} 249 | className="h-4 w-4 text-indigo-600 rounded border-gray-300 focus:ring-indigo-500" 250 | disabled={updateLoading === todo.id} 251 | /> 252 | 253 | {todo.completed ? 'Completed' : 'Pending'} 254 | 255 |
256 |
258 | {todo.user ? ( 259 |
260 |
{todo.user.name}
261 |
{todo.user.email}
262 |
263 | ) : ( 264 | Unknown User 265 | )} 266 |
268 | {new Date(todo.createdAt).toLocaleString()} 269 | 271 | {isEditing === todo.id ? ( 272 |
273 | 280 | 286 |
287 | ) : ( 288 |
289 | 295 | 302 |
303 | )} 304 |
310 |
311 | 312 | {/* Pagination */} 313 | {totalPages > 1 && ( 314 |
315 | 326 | 327 |
328 | Page {currentPage} of {totalPages} 329 |
330 | 331 | 342 |
343 | )} 344 |
345 |
346 | ); 347 | } 348 | --------------------------------------------------------------------------------