From 988a4eb71b4f3747634c785661962ab9dd61dbec Mon Sep 17 00:00:00 2001 From: RKWojs Date: Wed, 25 Jun 2025 13:37:10 +0200 Subject: [PATCH] feat: Implement user management functionality with CRUD operations; add user edit page, API routes for user actions, and enhance authentication middleware --- src/app/admin/users/[id]/edit/page.js | 336 +++++++++++++++++++++ src/app/admin/users/page.js | 418 ++++++++++++++++++++++++++ src/app/api/admin/users/[id]/route.js | 129 ++++++++ src/app/api/admin/users/route.js | 85 ++++++ src/lib/middleware/auth.js | 6 +- src/lib/userManagement.js | 156 +++++++++- 6 files changed, 1120 insertions(+), 10 deletions(-) create mode 100644 src/app/admin/users/[id]/edit/page.js create mode 100644 src/app/admin/users/page.js create mode 100644 src/app/api/admin/users/[id]/route.js create mode 100644 src/app/api/admin/users/route.js diff --git a/src/app/admin/users/[id]/edit/page.js b/src/app/admin/users/[id]/edit/page.js new file mode 100644 index 0000000..6a57ea0 --- /dev/null +++ b/src/app/admin/users/[id]/edit/page.js @@ -0,0 +1,336 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useSession } from "next-auth/react"; +import { useRouter, useParams } from "next/navigation"; +import Link from "next/link"; +import { Card, CardHeader, CardContent } from "@/components/ui/Card"; +import Button from "@/components/ui/Button"; +import { Input } from "@/components/ui/Input"; +import PageContainer from "@/components/ui/PageContainer"; +import PageHeader from "@/components/ui/PageHeader"; +import { LoadingState } from "@/components/ui/States"; + +export default function EditUserPage() { + const [user, setUser] = useState(null); + const [formData, setFormData] = useState({ + name: "", + email: "", + role: "user", + is_active: true, + password: "" + }); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + const [success, setSuccess] = useState(""); + + const { data: session, status } = useSession(); + const router = useRouter(); + const params = useParams(); + + // Check if user is admin + useEffect(() => { + if (status === "loading") return; + if (!session || session.user.role !== "admin") { + router.push("/"); + return; + } + }, [session, status, router]); + + // Fetch user data + useEffect(() => { + if (session?.user?.role === "admin" && params.id) { + fetchUser(); + } + }, [session, params.id]); + + const fetchUser = async () => { + try { + setLoading(true); + const response = await fetch(`/api/admin/users/${params.id}`); + + if (!response.ok) { + if (response.status === 404) { + setError("User not found"); + return; + } + throw new Error("Failed to fetch user"); + } + + const userData = await response.json(); + setUser(userData); + setFormData({ + name: userData.name, + email: userData.email, + role: userData.role, + is_active: userData.is_active, + password: "" // Never populate password field + }); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setSaving(true); + setError(""); + setSuccess(""); + + try { + // Prepare update data (exclude empty password) + const updateData = { + name: formData.name, + email: formData.email, + role: formData.role, + is_active: formData.is_active + }; + + // Only include password if it's provided + if (formData.password.trim()) { + if (formData.password.length < 6) { + throw new Error("Password must be at least 6 characters long"); + } + updateData.password = formData.password; + } + + const response = await fetch(`/api/admin/users/${params.id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(updateData), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to update user"); + } + + const updatedUser = await response.json(); + setUser(updatedUser); + setSuccess("User updated successfully"); + + // Clear password field after successful update + setFormData(prev => ({ ...prev, password: "" })); + + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + }; + + if (status === "loading" || !session) { + return ; + } + + if (session.user.role !== "admin") { + return ( + +
+

Access Denied

+

You need admin privileges to access this page.

+ + + +
+
+ ); + } + + if (loading) { + return ; + } + + if (error && !user) { + return ( + +
+

Error

+

{error}

+ + + +
+
+ ); + } + + return ( + + + + + + + + {error && ( +
+

{error}

+
+ )} + + {success && ( +
+

{success}

+
+ )} + + + +

User Information

+
+ +
+
+
+ + setFormData({ ...formData, name: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, email: e.target.value })} + required + /> +
+ +
+ + +
+ +
+ + setFormData({ ...formData, password: e.target.value })} + placeholder="Leave blank to keep current password" + minLength={6} + /> +

+ Leave blank to keep the current password +

+
+
+ +
+ setFormData({ ...formData, is_active: e.target.checked })} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + disabled={user?.id === session?.user?.id} + /> + +
+ +
+ + + + +
+
+
+
+ + {/* User Details Card */} + {user && ( + + +

Account Details

+
+ +
+
+

Created

+

{new Date(user.created_at).toLocaleDateString()}

+
+
+

Last Updated

+

+ {user.updated_at ? new Date(user.updated_at).toLocaleDateString() : "Never"} +

+
+
+

Last Login

+

+ {user.last_login ? new Date(user.last_login).toLocaleDateString() : "Never"} +

+
+
+

Failed Login Attempts

+

{user.failed_login_attempts || 0}

+
+
+

Account Status

+

+ {user.is_active ? "Active" : "Inactive"} +

+
+
+

Account Locked

+

+ {user.locked_until && new Date(user.locked_until) > new Date() + ? `Until ${new Date(user.locked_until).toLocaleDateString()}` + : "No" + } +

+
+
+
+
+ )} +
+ ); +} diff --git a/src/app/admin/users/page.js b/src/app/admin/users/page.js new file mode 100644 index 0000000..b2b1727 --- /dev/null +++ b/src/app/admin/users/page.js @@ -0,0 +1,418 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; +import Link from "next/link"; +import { Card, CardHeader, CardContent } from "@/components/ui/Card"; +import Button from "@/components/ui/Button"; +import Badge from "@/components/ui/Badge"; +import { Input } from "@/components/ui/Input"; +import PageContainer from "@/components/ui/PageContainer"; +import PageHeader from "@/components/ui/PageHeader"; +import { LoadingState } from "@/components/ui/States"; +import { formatDate } from "@/lib/utils"; + +export default function UserManagementPage() { + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [showCreateForm, setShowCreateForm] = useState(false); + const { data: session, status } = useSession(); + const router = useRouter(); + + // Check if user is admin + useEffect(() => { + if (status === "loading") return; + if (!session || session.user.role !== "admin") { + router.push("/"); + return; + } + }, [session, status, router]); + + // Fetch users + useEffect(() => { + if (session?.user?.role === "admin") { + fetchUsers(); + } + }, [session]); + + const fetchUsers = async () => { + try { + setLoading(true); + const response = await fetch("/api/admin/users"); + if (!response.ok) { + throw new Error("Failed to fetch users"); + } + const data = await response.json(); + setUsers(data); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleDeleteUser = async (userId) => { + if (!confirm("Are you sure you want to delete this user?")) return; + + try { + const response = await fetch(`/api/admin/users/${userId}`, { + method: "DELETE", + }); + + if (!response.ok) { + throw new Error("Failed to delete user"); + } + + setUsers(users.filter(user => user.id !== userId)); + } catch (err) { + setError(err.message); + } + }; + + const handleToggleUser = async (userId, isActive) => { + try { + const response = await fetch(`/api/admin/users/${userId}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ is_active: !isActive }), + }); + + if (!response.ok) { + throw new Error("Failed to update user"); + } + + setUsers(users.map(user => + user.id === userId + ? { ...user, is_active: !isActive } + : user + )); + } catch (err) { + setError(err.message); + } + }; + + const getRoleColor = (role) => { + switch (role) { + case "admin": + return "red"; + case "project_manager": + return "blue"; + case "user": + return "green"; + case "read_only": + return "gray"; + default: + return "gray"; + } + }; + + const getRoleDisplay = (role) => { + switch (role) { + case "project_manager": + return "Project Manager"; + case "read_only": + return "Read Only"; + default: + return role.charAt(0).toUpperCase() + role.slice(1); + } + }; + + if (status === "loading" || !session) { + return ; + } + + if (session.user.role !== "admin") { + return ( + +
+

Access Denied

+

You need admin privileges to access this page.

+ + + +
+
+ ); + } + + return ( + + + + + + {error && ( +
+

{error}

+
+ )} + + {loading ? ( + + ) : ( +
+ {/* Users List */} +
+ {users.length === 0 ? ( + + +
+ + + +

No Users Found

+

Start by creating your first user.

+
+
+
+ ) : ( + users.map((user) => ( + + +
+
+
+
+ + + +
+
+
+

{user.name}

+

{user.email}

+
+
+
+ + {getRoleDisplay(user.role)} + + + {user.is_active ? "Active" : "Inactive"} + +
+
+
+ +
+
+

Created

+

{formatDate(user.created_at)}

+
+
+

Last Login

+

+ {user.last_login ? formatDate(user.last_login) : "Never"} +

+
+
+

Failed Attempts

+

{user.failed_login_attempts || 0}

+
+
+ + {user.locked_until && new Date(user.locked_until) > new Date() && ( +
+

+ Account locked until {formatDate(user.locked_until)} +

+
+ )} + +
+
+ + + + +
+ +
+
+
+ )) + )} +
+
+ )} + + {/* Create User Modal/Form */} + {showCreateForm && ( + setShowCreateForm(false)} + onUserCreated={(newUser) => { + setUsers([...users, newUser]); + setShowCreateForm(false); + }} + /> + )} +
+ ); +} + +// Create User Modal Component +function CreateUserModal({ onClose, onUserCreated }) { + const [formData, setFormData] = useState({ + name: "", + email: "", + password: "", + role: "user", + is_active: true + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const handleSubmit = async (e) => { + e.preventDefault(); + setLoading(true); + setError(""); + + try { + const response = await fetch("/api/admin/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(formData), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to create user"); + } + + const newUser = await response.json(); + onUserCreated(newUser); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + return ( +
+
+
+

Create New User

+ +
+ + {error && ( +
+

{error}

+
+ )} + +
+
+ + setFormData({ ...formData, name: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, email: e.target.value })} + required + /> +
+ +
+ + setFormData({ ...formData, password: e.target.value })} + required + minLength={6} + /> +
+ +
+ + +
+ +
+ setFormData({ ...formData, is_active: e.target.checked })} + className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" + /> + +
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/app/api/admin/users/[id]/route.js b/src/app/api/admin/users/[id]/route.js new file mode 100644 index 0000000..b686f43 --- /dev/null +++ b/src/app/api/admin/users/[id]/route.js @@ -0,0 +1,129 @@ +import { getUserById, updateUser, deleteUser } from "@/lib/userManagement.js"; +import { NextResponse } from "next/server"; +import { withAdminAuth } from "@/lib/middleware/auth"; + +// GET: Get user by ID (admin only) +async function getUserHandler(req, { params }) { + try { + const user = getUserById(params.id); + + if (!user) { + return NextResponse.json( + { error: "User not found" }, + { status: 404 } + ); + } + + // Remove password hash from response + const { password_hash, ...safeUser } = user; + return NextResponse.json(safeUser); + + } catch (error) { + console.error("Error fetching user:", error); + return NextResponse.json( + { error: "Failed to fetch user" }, + { status: 500 } + ); + } +} + +// PUT: Update user (admin only) +async function updateUserHandler(req, { params }) { + try { + const data = await req.json(); + const userId = params.id; + + // Prevent admin from deactivating themselves + if (data.is_active === false && userId === req.user.id) { + return NextResponse.json( + { error: "You cannot deactivate your own account" }, + { status: 400 } + ); + } + + // Validate role if provided + if (data.role) { + const validRoles = ["read_only", "user", "project_manager", "admin"]; + if (!validRoles.includes(data.role)) { + return NextResponse.json( + { error: "Invalid role specified" }, + { status: 400 } + ); + } + } + + // Validate password length if provided + if (data.password && data.password.length < 6) { + return NextResponse.json( + { error: "Password must be at least 6 characters long" }, + { status: 400 } + ); + } + + const updatedUser = await updateUser(userId, data); + + if (!updatedUser) { + return NextResponse.json( + { error: "User not found" }, + { status: 404 } + ); + } + + // Remove password hash from response + const { password_hash, ...safeUser } = updatedUser; + return NextResponse.json(safeUser); + + } catch (error) { + console.error("Error updating user:", error); + + if (error.message.includes("already exists")) { + return NextResponse.json( + { error: "A user with this email already exists" }, + { status: 409 } + ); + } + + return NextResponse.json( + { error: "Failed to update user" }, + { status: 500 } + ); + } +} + +// DELETE: Delete user (admin only) +async function deleteUserHandler(req, { params }) { + try { + const userId = params.id; + + // Prevent admin from deleting themselves + if (userId === req.user.id) { + return NextResponse.json( + { error: "You cannot delete your own account" }, + { status: 400 } + ); + } + + const success = await deleteUser(userId); + + if (!success) { + return NextResponse.json( + { error: "User not found" }, + { status: 404 } + ); + } + + return NextResponse.json({ message: "User deleted successfully" }); + + } catch (error) { + console.error("Error deleting user:", error); + return NextResponse.json( + { error: "Failed to delete user" }, + { status: 500 } + ); + } +} + +// Protected routes - require admin authentication +export const GET = withAdminAuth(getUserHandler); +export const PUT = withAdminAuth(updateUserHandler); +export const DELETE = withAdminAuth(deleteUserHandler); diff --git a/src/app/api/admin/users/route.js b/src/app/api/admin/users/route.js new file mode 100644 index 0000000..324162c --- /dev/null +++ b/src/app/api/admin/users/route.js @@ -0,0 +1,85 @@ +import { getAllUsers, createUser } from "@/lib/userManagement.js"; +import { NextResponse } from "next/server"; +import { withAdminAuth } from "@/lib/middleware/auth"; + +// GET: Get all users (admin only) +async function getUsersHandler(req) { + try { + const users = getAllUsers(); + // Remove password hashes from response + const safeUsers = users.map(user => { + const { password_hash, ...safeUser } = user; + return safeUser; + }); + return NextResponse.json(safeUsers); + } catch (error) { + console.error("Error fetching users:", error); + return NextResponse.json( + { error: "Failed to fetch users" }, + { status: 500 } + ); + } +} + +// POST: Create new user (admin only) +async function createUserHandler(req) { + try { + const data = await req.json(); + + // Validate required fields + if (!data.name || !data.email || !data.password) { + return NextResponse.json( + { error: "Name, email, and password are required" }, + { status: 400 } + ); + } + + // Validate password length + if (data.password.length < 6) { + return NextResponse.json( + { error: "Password must be at least 6 characters long" }, + { status: 400 } + ); + } + + // Validate role + const validRoles = ["read_only", "user", "project_manager", "admin"]; + if (data.role && !validRoles.includes(data.role)) { + return NextResponse.json( + { error: "Invalid role specified" }, + { status: 400 } + ); + } + + const newUser = await createUser({ + name: data.name, + email: data.email, + password: data.password, + role: data.role || "user", + is_active: data.is_active !== undefined ? data.is_active : true + }); + + // Remove password hash from response + const { password_hash, ...safeUser } = newUser; + return NextResponse.json(safeUser, { status: 201 }); + + } catch (error) { + console.error("Error creating user:", error); + + if (error.message.includes("already exists")) { + return NextResponse.json( + { error: "A user with this email already exists" }, + { status: 409 } + ); + } + + return NextResponse.json( + { error: "Failed to create user" }, + { status: 500 } + ); + } +} + +// Protected routes - require admin authentication +export const GET = withAdminAuth(getUsersHandler); +export const POST = withAdminAuth(createUserHandler); diff --git a/src/lib/middleware/auth.js b/src/lib/middleware/auth.js index 70169c2..1d01a78 100644 --- a/src/lib/middleware/auth.js +++ b/src/lib/middleware/auth.js @@ -10,7 +10,7 @@ const ROLE_HIERARCHY = { } export function withAuth(handler, options = {}) { - return auth(async (req) => { + return auth(async (req, context) => { try { // Check if user is authenticated if (!req.auth?.user) { @@ -39,8 +39,8 @@ export function withAuth(handler, options = {}) { role: req.auth.user.role } - // Call the original handler - return await handler(req) + // Call the original handler with both req and context + return await handler(req, context) } catch (error) { console.error("Auth middleware error:", error) return NextResponse.json( diff --git a/src/lib/userManagement.js b/src/lib/userManagement.js index d95bad3..8c139c1 100644 --- a/src/lib/userManagement.js +++ b/src/lib/userManagement.js @@ -3,7 +3,7 @@ import bcrypt from "bcryptjs" import { randomBytes } from "crypto" // Create a new user -export async function createUser({ name, email, password, role = 'user' }) { +export async function createUser({ name, email, password, role = 'user', is_active = true }) { const existingUser = db.prepare("SELECT id FROM users WHERE email = ?").get(email) if (existingUser) { throw new Error("User with this email already exists") @@ -13,17 +13,22 @@ export async function createUser({ name, email, password, role = 'user' }) { const userId = randomBytes(16).toString('hex') const result = db.prepare(` - INSERT INTO users (id, name, email, password_hash, role) - VALUES (?, ?, ?, ?, ?) - `).run(userId, name, email, passwordHash, role) + INSERT INTO users (id, name, email, password_hash, role, is_active) + VALUES (?, ?, ?, ?, ?, ?) + `).run(userId, name, email, passwordHash, role, is_active ? 1 : 0) - return { id: userId, name, email, role } + return db.prepare(` + SELECT id, name, email, role, created_at, updated_at, last_login, + is_active, failed_login_attempts, locked_until + FROM users WHERE id = ? + `).get(userId) } // Get user by ID export function getUserById(id) { return db.prepare(` - SELECT id, name, email, role, created_at, last_login, is_active + SELECT id, name, email, password_hash, role, created_at, updated_at, last_login, + is_active, failed_login_attempts, locked_until FROM users WHERE id = ? `).get(id) } @@ -39,7 +44,7 @@ export function getUserByEmail(email) { // Get all users (for admin) export function getAllUsers() { return db.prepare(` - SELECT id, name, email, role, created_at, last_login, is_active, + SELECT id, name, email, password_hash, role, created_at, updated_at, last_login, is_active, failed_login_attempts, locked_until FROM users ORDER BY created_at DESC @@ -123,3 +128,140 @@ export function getUserAuditLogs(userId, limit = 50) { LIMIT ? `).all(userId, limit) } + +// Update user (comprehensive update function) +export async function updateUser(userId, updates) { + const user = getUserById(userId); + if (!user) { + return null; + } + + // Check if email is being changed and if it already exists + if (updates.email && updates.email !== user.email) { + const existingUser = db.prepare("SELECT id FROM users WHERE email = ? AND id != ?").get(updates.email, userId); + if (existingUser) { + throw new Error("User with this email already exists"); + } + } + + // Prepare update fields + const updateFields = []; + const updateValues = []; + + if (updates.name !== undefined) { + updateFields.push("name = ?"); + updateValues.push(updates.name); + } + + if (updates.email !== undefined) { + updateFields.push("email = ?"); + updateValues.push(updates.email); + } + + if (updates.role !== undefined) { + const validRoles = ['admin', 'project_manager', 'user', 'read_only']; + if (!validRoles.includes(updates.role)) { + throw new Error("Invalid role"); + } + updateFields.push("role = ?"); + updateValues.push(updates.role); + } + + if (updates.is_active !== undefined) { + updateFields.push("is_active = ?"); + updateValues.push(updates.is_active ? 1 : 0); + } + + if (updates.password !== undefined) { + const passwordHash = await bcrypt.hash(updates.password, 12); + updateFields.push("password_hash = ?"); + updateValues.push(passwordHash); + // Reset failed login attempts when password is changed + updateFields.push("failed_login_attempts = 0"); + updateFields.push("locked_until = NULL"); + } + + if (updateFields.length === 0) { + return getUserById(userId); // Return existing user if no updates + } + + updateFields.push("updated_at = CURRENT_TIMESTAMP"); + updateValues.push(userId); + + const query = ` + UPDATE users + SET ${updateFields.join(", ")} + WHERE id = ? + `; + + const result = db.prepare(query).run(...updateValues); + + if (result.changes > 0) { + return db.prepare(` + SELECT id, name, email, role, created_at, updated_at, last_login, + is_active, failed_login_attempts, locked_until + FROM users WHERE id = ? + `).get(userId); + } + + return null; +} + +// Delete user +export function deleteUser(userId) { + // First, delete related data (sessions, audit logs, etc.) + db.prepare("DELETE FROM sessions WHERE user_id = ?").run(userId); + db.prepare("DELETE FROM audit_logs WHERE user_id = ?").run(userId); + + // Then delete the user + const result = db.prepare("DELETE FROM users WHERE id = ?").run(userId); + + return result.changes > 0; +} + +// Reset user password (admin function) +export async function resetUserPassword(userId, newPassword) { + const passwordHash = await bcrypt.hash(newPassword, 12); + + const result = db.prepare(` + UPDATE users + SET password_hash = ?, + failed_login_attempts = 0, + locked_until = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(passwordHash, userId); + + return result.changes > 0; +} + +// Unlock user account +export function unlockUserAccount(userId) { + const result = db.prepare(` + UPDATE users + SET failed_login_attempts = 0, + locked_until = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? + `).run(userId); + + return result.changes > 0; +} + +// Get user statistics +export function getUserStats() { + const stats = db.prepare(` + SELECT + COUNT(*) as total_users, + COUNT(CASE WHEN is_active = 1 THEN 1 END) as active_users, + COUNT(CASE WHEN is_active = 0 THEN 1 END) as inactive_users, + COUNT(CASE WHEN role = 'admin' THEN 1 END) as admin_users, + COUNT(CASE WHEN role = 'project_manager' THEN 1 END) as manager_users, + COUNT(CASE WHEN role = 'user' THEN 1 END) as regular_users, + COUNT(CASE WHEN role = 'read_only' THEN 1 END) as readonly_users, + COUNT(CASE WHEN last_login IS NOT NULL THEN 1 END) as users_with_login + FROM users + `).get(); + + return stats; +}